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:
parent
a8eb3fe8e7
commit
f7051eaade
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 |
|
|
@ -24,7 +24,7 @@ import SubMenu from 'src/components/Menu/SubMenu';
|
|||
|
||||
const defaultProps = {
|
||||
name: 'Title',
|
||||
children: [
|
||||
tabs: [
|
||||
{
|
||||
name: 'Page1',
|
||||
label: 'Page1',
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { t } from '@superset-ui/core';
|
|||
|
||||
export const commonMenuData = {
|
||||
name: t('Data'),
|
||||
children: [
|
||||
tabs: [
|
||||
{
|
||||
name: 'Datasets',
|
||||
label: t('Datasets'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!'));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
|||
]
|
||||
search_columns = [
|
||||
"created_by",
|
||||
"changed_by",
|
||||
"datasource_id",
|
||||
"datasource_name",
|
||||
"datasource_type",
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
"owners",
|
||||
"published",
|
||||
"slug",
|
||||
"changed_by",
|
||||
)
|
||||
search_filters = {
|
||||
"dashboard_title": [DashboardTitleOrSlugFilter],
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ class QueryRestApi(BaseSupersetModelRestApi):
|
|||
"status",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"rows",
|
||||
"tmp_table_name",
|
||||
"tracking_url",
|
||||
]
|
||||
show_columns = [
|
||||
"client_id",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in New Issue