refactor: reduce number of api calls needed to fetch favorite status for charts and dashboards (#11502)

This commit is contained in:
ʈᵃᵢ 2020-11-02 21:26:14 -08:00 committed by GitHub
parent fd10c47bc6
commit edb9619731
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 421 additions and 176 deletions

View File

@ -23,7 +23,7 @@ import Icon from './Icon';
interface FaveStarProps { interface FaveStarProps {
itemId: number; itemId: number;
fetchFaveStar(id: number): any; fetchFaveStar?: (id: number) => void;
saveFaveStar(id: number, isStarred: boolean): any; saveFaveStar(id: number, isStarred: boolean): any;
isStarred: boolean; isStarred: boolean;
showTooltip?: boolean; showTooltip?: boolean;
@ -35,7 +35,9 @@ const StyledLink = styled.a`
export default class FaveStar extends React.PureComponent<FaveStarProps> { export default class FaveStar extends React.PureComponent<FaveStarProps> {
componentDidMount() { componentDidMount() {
this.props.fetchFaveStar(this.props.itemId); if (this.props.fetchFaveStar) {
this.props.fetchFaveStar(this.props.itemId);
}
} }
onClick = (e: React.MouseEvent) => { onClick = (e: React.MouseEvent) => {

View File

@ -152,11 +152,9 @@ interface CardProps {
coverLeft?: React.ReactNode; coverLeft?: React.ReactNode;
coverRight?: React.ReactNode; coverRight?: React.ReactNode;
actions: React.ReactNode | null; actions: React.ReactNode | null;
showImg?: boolean;
rows?: number | string; rows?: number | string;
avatar?: string; avatar?: string;
isRecent?: boolean; cover?: React.ReactNode | null;
renderCover?: React.ReactNode | null;
} }
function ListViewCard({ function ListViewCard({
@ -167,42 +165,39 @@ function ListViewCard({
imgFallbackURL, imgFallbackURL,
description, description,
coverLeft, coverLeft,
isRecent,
coverRight, coverRight,
actions, actions,
avatar, avatar,
loading, loading,
imgPosition = 'top', imgPosition = 'top',
renderCover, cover,
}: CardProps) { }: CardProps) {
return ( return (
<StyledCard <StyledCard
data-test="styled-card" data-test="styled-card"
cover={ cover={
!isRecent cover || (
? renderCover || ( <Cover>
<Cover> <a href={url}>
<a href={url}> <div className="gradient-container">
<div className="gradient-container"> <ImageLoader
<ImageLoader src={imgURL || ''}
src={imgURL || ''} fallback={imgFallbackURL || ''}
fallback={imgFallbackURL || ''} isLoading={loading}
isLoading={loading} position={imgPosition}
position={imgPosition} />
/> </div>
</div> </a>
</a> <CoverFooter className="cover-footer">
<CoverFooter className="cover-footer"> {!loading && coverLeft && (
{!loading && coverLeft && ( <CoverFooterLeft>{coverLeft}</CoverFooterLeft>
<CoverFooterLeft>{coverLeft}</CoverFooterLeft> )}
)} {!loading && coverRight && (
{!loading && coverRight && ( <CoverFooterRight>{coverRight}</CoverFooterRight>
<CoverFooterRight>{coverRight}</CoverFooterRight> )}
)} </CoverFooter>
</CoverFooter> </Cover>
</Cover> )
)
: null
} }
> >
{loading && ( {loading && (

View File

@ -17,7 +17,6 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import { useFavoriteStatus } from 'src/views/CRUD/hooks';
import { t } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import Icon from 'src/components/Icon'; import Icon from 'src/components/Icon';
@ -30,8 +29,6 @@ import FaveStar from 'src/components/FaveStar';
import FacePile from 'src/components/FacePile'; import FacePile from 'src/components/FacePile';
import { handleChartDelete } from '../utils'; import { handleChartDelete } from '../utils';
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
interface ChartCardProps { interface ChartCardProps {
chart: Chart; chart: Chart;
hasPerm: (perm: string) => boolean; hasPerm: (perm: string) => boolean;
@ -41,6 +38,8 @@ interface ChartCardProps {
addSuccessToast: (msg: string) => void; addSuccessToast: (msg: string) => void;
refreshData: () => void; refreshData: () => void;
loading: boolean; loading: boolean;
saveFavoriteStatus: (id: number, isStarred: boolean) => void;
favoriteStatus: boolean;
} }
export default function ChartCard({ export default function ChartCard({
@ -52,14 +51,11 @@ export default function ChartCard({
addSuccessToast, addSuccessToast,
refreshData, refreshData,
loading, loading,
saveFavoriteStatus,
favoriteStatus,
}: ChartCardProps) { }: ChartCardProps) {
const canEdit = hasPerm('can_edit'); const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete'); const canDelete = hasPerm('can_delete');
const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus(
{},
FAVESTAR_BASE_URL,
addDangerToast,
);
const menu = ( const menu = (
<Menu> <Menu>
@ -124,9 +120,8 @@ export default function ChartCard({
<ListViewCard.Actions> <ListViewCard.Actions>
<FaveStar <FaveStar
itemId={chart.id} itemId={chart.id}
fetchFaveStar={fetchFaveStar} saveFaveStar={saveFavoriteStatus}
saveFaveStar={saveFaveStar} isStarred={favoriteStatus}
isStarred={!!favoriteStatus[chart.id]}
/> />
<Dropdown overlay={menu}> <Dropdown overlay={menu}>
<Icon name="more-horiz" /> <Icon name="more-horiz" />

View File

@ -47,7 +47,6 @@ import TooltipWrapper from 'src/components/TooltipWrapper';
import ChartCard from './ChartCard'; import ChartCard from './ChartCard';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
const createFetchDatasets = (handleError: (err: Response) => void) => async ( const createFetchDatasets = (handleError: (err: Response) => void) => async (
filterValue = '', filterValue = '',
@ -105,9 +104,12 @@ function ChartList(props: ChartListProps) {
toggleBulkSelect, toggleBulkSelect,
refreshData, refreshData,
} = useListViewResource<Chart>('chart', t('chart'), props.addDangerToast); } = useListViewResource<Chart>('chart', t('chart'), props.addDangerToast);
const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus(
{}, const chartIds = useMemo(() => charts.map(c => c.id), [charts]);
FAVESTAR_BASE_URL,
const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
'chart',
chartIds,
props.addDangerToast, props.addDangerToast,
); );
const { const {
@ -140,17 +142,6 @@ function ChartList(props: ChartListProps) {
); );
} }
function renderFaveStar(id: number) {
return (
<FaveStar
itemId={id}
fetchFaveStar={fetchFaveStar}
saveFaveStar={saveFaveStar}
isStarred={!!favoriteStatusRef.current[id]}
/>
);
}
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@ -158,7 +149,13 @@ function ChartList(props: ChartListProps) {
row: { row: {
original: { id }, original: { id },
}, },
}: any) => renderFaveStar(id), }: any) => (
<FaveStar
itemId={id}
saveFaveStar={saveFavoriteStatus}
isStarred={favoriteStatus[id]}
/>
),
Header: '', Header: '',
id: 'favorite', id: 'favorite',
disableSortBy: true, disableSortBy: true,
@ -303,7 +300,7 @@ function ChartList(props: ChartListProps) {
hidden: !canEdit && !canDelete, hidden: !canEdit && !canDelete,
}, },
], ],
[canEdit, canDelete], [canEdit, canDelete, favoriteStatus],
); );
const filters: Filters = [ const filters: Filters = [
@ -415,6 +412,8 @@ function ChartList(props: ChartListProps) {
addSuccessToast={props.addSuccessToast} addSuccessToast={props.addSuccessToast}
refreshData={refreshData} refreshData={refreshData}
loading={loading} loading={loading}
favoriteStatus={favoriteStatus[chart.id]}
saveFavoriteStatus={saveFavoriteStatus}
/> />
); );
} }

View File

@ -29,11 +29,21 @@ import Icon from 'src/components/Icon';
import Label from 'src/components/Label'; import Label from 'src/components/Label';
import FacePile from 'src/components/FacePile'; import FacePile from 'src/components/FacePile';
import FaveStar from 'src/components/FaveStar'; import FaveStar from 'src/components/FaveStar';
import { DashboardCardProps } from 'src/views/CRUD/types'; import { Dashboard } from 'src/views/CRUD/types';
import { useFavoriteStatus } from 'src/views/CRUD/hooks'; export interface DashboardCardProps {
isChart?: boolean;
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard'; dashboard: Dashboard;
hasPerm: (name: string) => boolean;
bulkSelectEnabled: boolean;
refreshData: () => void;
loading: boolean;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
openDashboardEditModal?: (d: Dashboard) => void;
saveFavoriteStatus: (id: number, isStarred: boolean) => void;
favoriteStatus: boolean;
}
function DashboardCard({ function DashboardCard({
dashboard, dashboard,
@ -43,15 +53,12 @@ function DashboardCard({
addDangerToast, addDangerToast,
addSuccessToast, addSuccessToast,
openDashboardEditModal, openDashboardEditModal,
favoriteStatus,
saveFavoriteStatus,
}: DashboardCardProps) { }: DashboardCardProps) {
const canEdit = hasPerm('can_edit'); const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete'); const canDelete = hasPerm('can_delete');
const canExport = hasPerm('can_mulexport'); const canExport = hasPerm('can_mulexport');
const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus(
{},
FAVESTAR_BASE_URL,
addDangerToast,
);
const menu = ( const menu = (
<Menu> <Menu>
@ -123,16 +130,14 @@ function DashboardCard({
<ListViewCard.Actions> <ListViewCard.Actions>
<FaveStar <FaveStar
itemId={dashboard.id} itemId={dashboard.id}
fetchFaveStar={fetchFaveStar} saveFaveStar={saveFavoriteStatus}
saveFaveStar={saveFaveStar} isStarred={favoriteStatus}
isStarred={!!favoriteStatus[dashboard.id]}
/> />
<Dropdown overlay={menu}> <Dropdown overlay={menu}>
<Icon name="more-horiz" /> <Icon name="more-horiz" />
</Dropdown> </Dropdown>
</ListViewCard.Actions> </ListViewCard.Actions>
} }
showImg
/> />
); );
} }

View File

@ -42,7 +42,6 @@ import Dashboard from 'src/dashboard/containers/Dashboard';
import DashboardCard from './DashboardCard'; import DashboardCard from './DashboardCard';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
interface DashboardListProps { interface DashboardListProps {
addDangerToast: (msg: string) => void; addDangerToast: (msg: string) => void;
@ -81,9 +80,11 @@ function DashboardList(props: DashboardListProps) {
t('dashboard'), t('dashboard'),
props.addDangerToast, props.addDangerToast,
); );
const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus(
{}, const dashboardIds = useMemo(() => dashboards.map(d => d.id), [dashboards]);
FAVESTAR_BASE_URL, const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
'dashboard',
dashboardIds,
props.addDangerToast, props.addDangerToast,
); );
const [dashboardToEdit, setDashboardToEdit] = useState<Dashboard | null>( const [dashboardToEdit, setDashboardToEdit] = useState<Dashboard | null>(
@ -140,17 +141,6 @@ function DashboardList(props: DashboardListProps) {
); );
} }
function renderFaveStar(id: number) {
return (
<FaveStar
itemId={id}
fetchFaveStar={fetchFaveStar}
saveFaveStar={saveFaveStar}
isStarred={!!favoriteStatusRef.current[id]}
/>
);
}
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@ -158,7 +148,13 @@ function DashboardList(props: DashboardListProps) {
row: { row: {
original: { id }, original: { id },
}, },
}: any) => renderFaveStar(id), }: any) => (
<FaveStar
itemId={id}
saveFaveStar={saveFavoriteStatus}
isStarred={favoriteStatus[id]}
/>
),
Header: '', Header: '',
id: 'favorite', id: 'favorite',
disableSortBy: true, disableSortBy: true,
@ -317,7 +313,7 @@ function DashboardList(props: DashboardListProps) {
disableSortBy: true, disableSortBy: true,
}, },
], ],
[canEdit, canDelete, canExport, favoriteStatusRef], [canEdit, canDelete, canExport, favoriteStatus],
); );
const filters: Filters = [ const filters: Filters = [
@ -404,15 +400,16 @@ function DashboardList(props: DashboardListProps) {
function renderCard(dashboard: Dashboard) { function renderCard(dashboard: Dashboard) {
return ( return (
<DashboardCard <DashboardCard
{...{ dashboard={dashboard}
dashboard, hasPerm={hasPerm}
hasPerm, bulkSelectEnabled={bulkSelectEnabled}
bulkSelectEnabled, refreshData={refreshData}
refreshData, loading={loading}
addDangerToast: props.addDangerToast, addDangerToast={props.addDangerToast}
addSuccessToast: props.addSuccessToast, addSuccessToast={props.addSuccessToast}
openDashboardEditModal, openDashboardEditModal={openDashboardEditModal}
}} saveFavoriteStatus={saveFavoriteStatus}
favoriteStatus={favoriteStatus[dashboard.id]}
/> />
); );
} }

View File

@ -17,8 +17,8 @@
* under the License. * under the License.
*/ */
import rison from 'rison'; import rison from 'rison';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { SupersetClient, t } from '@superset-ui/core'; import { makeApi, SupersetClient, t } from '@superset-ui/core';
import { createErrorHandler } from 'src/views/CRUD/utils'; import { createErrorHandler } from 'src/views/CRUD/utils';
import { FetchDataConfig } from 'src/components/ListView'; import { FetchDataConfig } from 'src/components/ListView';
@ -58,7 +58,9 @@ export function useListViewResource<D extends object = any>(
} }
useEffect(() => { useEffect(() => {
const infoParam = infoEnable ? '_info?q=(keys:!(permissions))' : ''; const infoParam = infoEnable
? `_info?q=${rison.encode({ keys: ['permissions'] })}`
: '';
SupersetClient.get({ SupersetClient.get({
endpoint: `/api/v1/${resource}/${infoParam}`, endpoint: `/api/v1/${resource}/${infoParam}`,
}).then( }).then(
@ -299,32 +301,52 @@ export function useSingleViewResource<D extends object = any>(
}; };
} }
// the hooks api has some known limitations around stale state in closures. enum FavStarClassName {
// See https://github.com/reactjs/rfcs/blob/master/text/0068-react-hooks.md#drawbacks CHART = 'slice',
// the useRef hook is a way of getting around these limitations by having a consistent ref DASHBOARD = 'Dashboard',
// that points to the most recent value. }
type FavoriteStatusResponse = {
result: Array<{
id: string;
value: boolean;
}>;
};
const favoriteApis = {
chart: makeApi<string, FavoriteStatusResponse>({
requestType: 'search',
method: 'GET',
endpoint: '/api/v1/chart/favorite_status',
}),
dashboard: makeApi<string, FavoriteStatusResponse>({
requestType: 'search',
method: 'GET',
endpoint: '/api/v1/dashboard/favorite_status',
}),
};
export function useFavoriteStatus( export function useFavoriteStatus(
initialState: FavoriteStatus, type: 'chart' | 'dashboard',
baseURL: string, ids: Array<string | number>,
handleErrorMsg: (message: string) => void, handleErrorMsg: (message: string) => void,
) { ) {
const [favoriteStatus, setFavoriteStatus] = useState<FavoriteStatus>( const [favoriteStatus, setFavoriteStatus] = useState<FavoriteStatus>({});
initialState,
);
const favoriteStatusRef = useRef<FavoriteStatus>(favoriteStatus);
useEffect(() => {
favoriteStatusRef.current = favoriteStatus;
});
const updateFavoriteStatus = (update: FavoriteStatus) => const updateFavoriteStatus = (update: FavoriteStatus) =>
setFavoriteStatus(currentState => ({ ...currentState, ...update })); setFavoriteStatus(currentState => ({ ...currentState, ...update }));
const fetchFaveStar = (id: number) => { useEffect(() => {
SupersetClient.get({ if (!ids.length) {
endpoint: `${baseURL}/${id}/count/`, return;
}).then( }
({ json }) => { favoriteApis[type](`q=${rison.encode(ids)}`).then(
updateFavoriteStatus({ [id]: json.count > 0 }); ({ result }) => {
const update = result.reduce((acc, element) => {
acc[element.id] = element.value;
return acc;
}, {});
updateFavoriteStatus(update);
}, },
createErrorHandler(errMsg => createErrorHandler(errMsg =>
handleErrorMsg( handleErrorMsg(
@ -332,31 +354,32 @@ export function useFavoriteStatus(
), ),
), ),
); );
}; }, [ids]);
const saveFaveStar = (id: number, isStarred: boolean) => { const saveFaveStar = useCallback(
const urlSuffix = isStarred ? 'unselect' : 'select'; (id: number, isStarred: boolean) => {
const urlSuffix = isStarred ? 'unselect' : 'select';
SupersetClient.get({ SupersetClient.get({
endpoint: `${baseURL}/${id}/${urlSuffix}/`, endpoint: `/superset/favstar/${
}).then( type === 'chart' ? FavStarClassName.CHART : FavStarClassName.DASHBOARD
() => { }/${id}/${urlSuffix}/`,
updateFavoriteStatus({ [id]: !isStarred }); }).then(
}, ({ json }) => {
createErrorHandler(errMsg => updateFavoriteStatus({
handleErrorMsg( [id]: (json as { count: number })?.count > 0,
t('There was an error saving the favorite status: %s', errMsg), });
},
createErrorHandler(errMsg =>
handleErrorMsg(
t('There was an error saving the favorite status: %s', errMsg),
),
), ),
), );
); },
}; [type],
);
return [ return [saveFaveStar, favoriteStatus] as const;
favoriteStatusRef,
fetchFaveStar,
saveFaveStar,
favoriteStatus,
] as const;
} }
export const useChartEditModal = ( export const useChartEditModal = (

View File

@ -45,17 +45,6 @@ export interface Dashboard {
loading?: boolean; 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 = { export type SavedQueryObject = {
database: { database: {
database_name: string; database_name: string;

View File

@ -60,7 +60,7 @@ const createFetchResourceMethod = (method: string) => (
export const getRecentAcitivtyObjs = ( export const getRecentAcitivtyObjs = (
userId: string | number, userId: string | number,
recent: string, recent: string,
addDangerToast: (arg0: string, arg1: string) => void, addDangerToast: (arg1: string, arg2: any) => any,
) => { ) => {
const getParams = (filters?: Array<any>) => { const getParams = (filters?: Array<any>) => {
const params = { const params = {

View File

@ -177,8 +177,8 @@ export default function ActivityTable({ user }: ActivityProps) {
return activityData[activeChild].map((e: ActivityObjects) => ( return activityData[activeChild].map((e: ActivityObjects) => (
<ListViewCard <ListViewCard
key={`${e.id}`} key={`${e.id}`}
isRecent
loading={loading} loading={loading}
cover={<></>}
url={e.sql ? `/supserset/sqllab?queryId=${e.id}` : e.url} url={e.sql ? `/supserset/sqllab?queryId=${e.id}` : e.url}
title={getFilterTitle(e)} title={getFilterTitle(e)}
description={`Last Edited: ${moment(e.changed_on_utc).format( description={`Last Edited: ${moment(e.changed_on_utc).format(

View File

@ -16,9 +16,13 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { t } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import { useListViewResource, useChartEditModal } from 'src/views/CRUD/hooks'; import {
useListViewResource,
useChartEditModal,
useFavoriteStatus,
} from 'src/views/CRUD/hooks';
import withToasts from 'src/messageToasts/enhancers/withToasts'; import withToasts from 'src/messageToasts/enhancers/withToasts';
import PropertiesModal from 'src/explore/components/PropertiesModal'; import PropertiesModal from 'src/explore/components/PropertiesModal';
import { User } from 'src/types/bootstrapTypes'; import { User } from 'src/types/bootstrapTypes';
@ -51,6 +55,12 @@ function ChartTable({
refreshData, refreshData,
fetchData, fetchData,
} = useListViewResource<Chart>('chart', t('chart'), addDangerToast); } = useListViewResource<Chart>('chart', t('chart'), addDangerToast);
const chartIds = useMemo(() => charts.map(c => c.id), [charts]);
const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
'chart',
chartIds,
addDangerToast,
);
const { const {
sliceCurrentlyEditing, sliceCurrentlyEditing,
openChartEditModal, openChartEditModal,
@ -154,6 +164,8 @@ function ChartTable({
refreshData={refreshData} refreshData={refreshData}
addDangerToast={addDangerToast} addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast} addSuccessToast={addSuccessToast}
favoriteStatus={favoriteStatus[e.id]}
saveFavoriteStatus={saveFavoriteStatus}
/> />
))} ))}
</CardContainer> </CardContainer>

View File

@ -16,9 +16,9 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { SupersetClient, t } from '@superset-ui/core'; import { SupersetClient, t } from '@superset-ui/core';
import { useListViewResource } from 'src/views/CRUD/hooks'; import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import { Dashboard, DashboardTableProps } from 'src/views/CRUD/types'; import { Dashboard, DashboardTableProps } from 'src/views/CRUD/types';
import withToasts from 'src/messageToasts/enhancers/withToasts'; import withToasts from 'src/messageToasts/enhancers/withToasts';
import PropertiesModal from 'src/dashboard/components/PropertiesModal'; import PropertiesModal from 'src/dashboard/components/PropertiesModal';
@ -42,7 +42,7 @@ function DashboardTable({
addSuccessToast, addSuccessToast,
}: DashboardTableProps) { }: DashboardTableProps) {
const { const {
state: { loading, resourceCollection: dashboards, bulkSelectEnabled }, state: { loading, resourceCollection: dashboards },
setResourceCollection: setDashboards, setResourceCollection: setDashboards,
hasPerm, hasPerm,
refreshData, refreshData,
@ -52,7 +52,12 @@ function DashboardTable({
t('dashboard'), t('dashboard'),
addDangerToast, addDangerToast,
); );
const dashboardIds = useMemo(() => dashboards.map(c => c.id), [dashboards]);
const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
'dashboard',
dashboardIds,
addDangerToast,
);
const [editModal, setEditModal] = useState<Dashboard>(); const [editModal, setEditModal] = useState<Dashboard>();
const [dashboardFilter, setDashboardFilter] = useState('Mine'); const [dashboardFilter, setDashboardFilter] = useState('Mine');
@ -168,16 +173,16 @@ function DashboardTable({
<CardContainer> <CardContainer>
{dashboards.map(e => ( {dashboards.map(e => (
<DashboardCard <DashboardCard
{...{ dashboard={e}
dashboard: e, hasPerm={hasPerm}
hasPerm, bulkSelectEnabled={false}
bulkSelectEnabled, refreshData={refreshData}
refreshData, addDangerToast={addDangerToast}
addDangerToast, addSuccessToast={addSuccessToast}
addSuccessToast, loading={loading}
loading, openDashboardEditModal={dashboard => setEditModal(dashboard)}
openDashboardEditModal: dashboard => setEditModal(dashboard), saveFavoriteStatus={saveFavoriteStatus}
}} favoriteStatus={favoriteStatus[e.id]}
/> />
))} ))}
</CardContainer> </CardContainer>

View File

@ -227,8 +227,7 @@ const SavedQueries = ({
rows={q.rows} rows={q.rows}
loading={loading} loading={loading}
description={t('Last run ', q.end_time)} description={t('Last run ', q.end_time)}
showImg={false} cover={
renderCover={
<QueryData> <QueryData>
<div className="holder"> <div className="holder">
<div className="title">{t('Tables')}</div> <div className="title">{t('Tables')}</div>

View File

@ -45,6 +45,7 @@ from superset.charts.commands.exceptions import (
) )
from superset.charts.commands.export import ExportChartsCommand from superset.charts.commands.export import ExportChartsCommand
from superset.charts.commands.update import UpdateChartCommand from superset.charts.commands.update import UpdateChartCommand
from superset.charts.dao import ChartDAO
from superset.charts.filters import ChartAllTextFilter, ChartFavoriteFilter, ChartFilter from superset.charts.filters import ChartAllTextFilter, ChartFavoriteFilter, ChartFilter
from superset.charts.schemas import ( from superset.charts.schemas import (
CHART_SCHEMAS, CHART_SCHEMAS,
@ -53,6 +54,7 @@ from superset.charts.schemas import (
ChartPutSchema, ChartPutSchema,
get_delete_ids_schema, get_delete_ids_schema,
get_export_ids_schema, get_export_ids_schema,
get_fav_star_ids_schema,
openapi_spec_methods_override, openapi_spec_methods_override,
screenshot_query_schema, screenshot_query_schema,
thumbnail_query_schema, thumbnail_query_schema,
@ -87,7 +89,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
RouteMethod.RELATED, RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined "bulk_delete", # not using RouteMethod since locally defined
"data", "data",
"viz_types", "favorite_status",
} }
class_permission_name = "SliceModelView" class_permission_name = "SliceModelView"
show_columns = [ show_columns = [
@ -176,6 +178,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
"screenshot_query_schema": screenshot_query_schema, "screenshot_query_schema": screenshot_query_schema,
"get_delete_ids_schema": get_delete_ids_schema, "get_delete_ids_schema": get_delete_ids_schema,
"get_export_ids_schema": get_export_ids_schema, "get_export_ids_schema": get_export_ids_schema,
"get_fav_star_ids_schema": get_fav_star_ids_schema,
} }
""" Add extra schemas to the OpenAPI components schema section """ """ Add extra schemas to the OpenAPI components schema section """
openapi_spec_methods = openapi_spec_methods_override openapi_spec_methods = openapi_spec_methods_override
@ -773,3 +776,48 @@ class ChartRestApi(BaseSupersetModelRestApi):
as_attachment=True, as_attachment=True,
attachment_filename=filename, attachment_filename=filename,
) )
@expose("/favorite_status/", methods=["GET"])
@protect()
@safe
@statsd_metrics
@rison(get_fav_star_ids_schema)
def favorite_status(self, **kwargs: Any) -> Response:
"""Favorite stars for Charts
---
get:
description: >-
Check favorited dashboards for current user
parameters:
- in: query
name: q
content:
application/json:
schema:
$ref: '#/components/schemas/get_fav_star_ids_schema'
responses:
200:
description:
content:
application/json:
schema:
$ref: "#/components/schemas/GetFavStarIdsSchema"
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
requested_ids = kwargs["rison"]
charts = ChartDAO.find_by_ids(requested_ids)
if not charts:
return self.response_404()
favorited_chart_ids = ChartDAO.favorited_ids(charts, g.user.id)
res = [
{"id": request_id, "value": request_id in favorited_chart_ids}
for request_id in requested_ids
]
return self.response(200, result=res)

View File

@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError
from superset.charts.filters import ChartFilter from superset.charts.filters import ChartFilter
from superset.dao.base import BaseDAO from superset.dao.base import BaseDAO
from superset.extensions import db from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
from superset.models.slice import Slice from superset.models.slice import Slice
if TYPE_CHECKING: if TYPE_CHECKING:
@ -66,3 +67,17 @@ class ChartDAO(BaseDAO):
db.session.merge(slc) db.session.merge(slc)
if commit: if commit:
db.session.commit() db.session.commit()
@staticmethod
def favorited_ids(charts: List[Slice], current_user_id: int) -> List[FavStar]:
ids = [chart.id for chart in charts]
return [
star.obj_id
for star in db.session.query(FavStar.obj_id)
.filter(
FavStar.class_name == FavStarClassName.CHART,
FavStar.obj_id.in_(ids),
FavStar.user_id == current_user_id,
)
.all()
]

View File

@ -47,6 +47,8 @@ screenshot_query_schema = {
} }
get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
get_fav_star_ids_schema = {"type": "array", "items": {"type": "integer"}}
# #
# Column schema descriptions # Column schema descriptions
# #
@ -1031,6 +1033,18 @@ class ChartDataResponseSchema(Schema):
) )
class ChartFavStarResponseResult(Schema):
id = fields.Integer(description="The Chart id")
value = fields.Boolean(description="The FaveStar value")
class GetFavStarIdsSchema(Schema):
result = fields.List(
fields.Nested(ChartFavStarResponseResult),
description="A list of results for each corresponding chart in the request",
)
CHART_SCHEMAS = ( CHART_SCHEMAS = (
ChartDataQueryContextSchema, ChartDataQueryContextSchema,
ChartDataResponseSchema, ChartDataResponseSchema,
@ -1049,4 +1063,5 @@ CHART_SCHEMAS = (
ChartDataGeodeticParseOptionsSchema, ChartDataGeodeticParseOptionsSchema,
ChartGetDatasourceResponseSchema, ChartGetDatasourceResponseSchema,
ChartCacheScreenshotResponseSchema, ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,
) )

View File

@ -44,6 +44,7 @@ from superset.dashboards.commands.exceptions import (
) )
from superset.dashboards.commands.export import ExportDashboardsCommand from superset.dashboards.commands.export import ExportDashboardsCommand
from superset.dashboards.commands.update import UpdateDashboardCommand from superset.dashboards.commands.update import UpdateDashboardCommand
from superset.dashboards.dao import DashboardDAO
from superset.dashboards.filters import ( from superset.dashboards.filters import (
DashboardFavoriteFilter, DashboardFavoriteFilter,
DashboardFilter, DashboardFilter,
@ -54,6 +55,8 @@ from superset.dashboards.schemas import (
DashboardPutSchema, DashboardPutSchema,
get_delete_ids_schema, get_delete_ids_schema,
get_export_ids_schema, get_export_ids_schema,
get_fav_star_ids_schema,
GetFavStarIdsSchema,
openapi_spec_methods_override, openapi_spec_methods_override,
thumbnail_query_schema, thumbnail_query_schema,
) )
@ -78,6 +81,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
RouteMethod.EXPORT, RouteMethod.EXPORT,
RouteMethod.RELATED, RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined "bulk_delete", # not using RouteMethod since locally defined
"favorite_status",
} }
resource_name = "dashboard" resource_name = "dashboard"
allow_browser_login = True allow_browser_login = True
@ -181,10 +185,13 @@ class DashboardRestApi(BaseSupersetModelRestApi):
allowed_rel_fields = {"owners", "created_by"} allowed_rel_fields = {"owners", "created_by"}
openapi_spec_tag = "Dashboards" openapi_spec_tag = "Dashboards"
""" Override the name set for this collection of endpoints """
openapi_spec_component_schemas = (GetFavStarIdsSchema,)
apispec_parameter_schemas = { apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema, "get_delete_ids_schema": get_delete_ids_schema,
"get_export_ids_schema": get_export_ids_schema, "get_export_ids_schema": get_export_ids_schema,
"thumbnail_query_schema": thumbnail_query_schema, "thumbnail_query_schema": thumbnail_query_schema,
"get_fav_star_ids_schema": get_fav_star_ids_schema,
} }
openapi_spec_methods = openapi_spec_methods_override openapi_spec_methods = openapi_spec_methods_override
""" Overrides GET methods OpenApi descriptions """ """ Overrides GET methods OpenApi descriptions """
@ -589,3 +596,48 @@ class DashboardRestApi(BaseSupersetModelRestApi):
return Response( return Response(
FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True
) )
@expose("/favorite_status/", methods=["GET"])
@protect()
@safe
@statsd_metrics
@rison(get_fav_star_ids_schema)
def favorite_status(self, **kwargs: Any) -> Response:
"""Favorite Stars for Dashboards
---
get:
description: >-
Check favorited dashboards for current user
parameters:
- in: query
name: q
content:
application/json:
schema:
$ref: '#/components/schemas/get_fav_star_ids_schema'
responses:
200:
description:
content:
application/json:
schema:
$ref: "#/components/schemas/GetFavStarIdsSchema"
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
requested_ids = kwargs["rison"]
dashboards = DashboardDAO.find_by_ids(requested_ids)
if not dashboards:
return self.response_404()
favorited_dashboard_ids = DashboardDAO.favorited_ids(dashboards, g.user.id)
res = [
{"id": request_id, "value": request_id in favorited_dashboard_ids}
for request_id in requested_ids
]
return self.response(200, result=res)

View File

@ -23,6 +23,7 @@ from sqlalchemy.exc import SQLAlchemyError
from superset.dao.base import BaseDAO from superset.dao.base import BaseDAO
from superset.dashboards.filters import DashboardFilter from superset.dashboards.filters import DashboardFilter
from superset.extensions import db from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.utils.dashboard_filter_scopes_converter import copy_filter_scopes from superset.utils.dashboard_filter_scopes_converter import copy_filter_scopes
@ -154,3 +155,19 @@ class DashboardDAO(BaseDAO):
if data.get("label_colors"): if data.get("label_colors"):
md["label_colors"] = data.get("label_colors") md["label_colors"] = data.get("label_colors")
dashboard.json_metadata = json.dumps(md) dashboard.json_metadata = json.dumps(md)
@staticmethod
def favorited_ids(
dashboards: List[Dashboard], current_user_id: int
) -> List[FavStar]:
ids = [dash.id for dash in dashboards]
return [
star.obj_id
for star in db.session.query(FavStar.obj_id)
.filter(
FavStar.class_name == FavStarClassName.DASHBOARD,
FavStar.obj_id.in_(ids),
FavStar.user_id == current_user_id,
)
.all()
]

View File

@ -26,6 +26,7 @@ from superset.utils import core as utils
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
get_fav_star_ids_schema = {"type": "array", "items": {"type": "integer"}}
thumbnail_query_schema = { thumbnail_query_schema = {
"type": "object", "type": "object",
"properties": {"force": {"type": "boolean"}}, "properties": {"force": {"type": "boolean"}},
@ -163,3 +164,15 @@ class DashboardPutSchema(BaseDashboardSchema):
validate=validate_json_metadata, validate=validate_json_metadata,
) )
published = fields.Boolean(description=published_description, allow_none=True) published = fields.Boolean(description=published_description, allow_none=True)
class ChartFavStarResponseResult(Schema):
id = fields.Integer(description="The Chart id")
value = fields.Boolean(description="The FaveStar value")
class GetFavStarIdsSchema(Schema):
result = fields.List(
fields.Nested(ChartFavStarResponseResult),
description="A list of results for each corresponding chart in the request",
)

View File

@ -22,6 +22,7 @@ import textwrap
from contextlib import closing from contextlib import closing
from copy import deepcopy from copy import deepcopy
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type
import numpy import numpy
@ -707,6 +708,11 @@ class Log(Model): # pylint: disable=too-few-public-methods
referrer = Column(String(1024)) referrer = Column(String(1024))
class FavStarClassName(str, Enum):
CHART = "slice"
DASHBOARD = "Dashboard"
class FavStar(Model): # pylint: disable=too-few-public-methods class FavStar(Model): # pylint: disable=too-few-public-methods
__tablename__ = "favstar" __tablename__ = "favstar"

View File

@ -35,7 +35,7 @@ from tests.fixtures.unicode_dashboard import load_unicode_dashboard_with_slice
from tests.test_app import app from tests.test_app import app
from superset.connectors.connector_registry import ConnectorRegistry from superset.connectors.connector_registry import ConnectorRegistry
from superset.extensions import db, security_manager from superset.extensions import db, security_manager
from superset.models.core import FavStar from superset.models.core import FavStar, FavStarClassName
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.utils import core as utils from superset.utils import core as utils
@ -776,6 +776,35 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin):
assert rv.status_code == 200 assert rv.status_code == 200
assert len(expected_models) == data["count"] assert len(expected_models) == data["count"]
@pytest.mark.usefixtures("create_charts")
def test_get_current_user_favorite_status(self):
"""
Dataset API: Test get current user favorite stars
"""
admin = self.get_user("admin")
users_favorite_ids = [
star.obj_id
for star in db.session.query(FavStar.obj_id)
.filter(
and_(
FavStar.user_id == admin.id,
FavStar.class_name == FavStarClassName.CHART,
)
)
.all()
]
assert users_favorite_ids
arguments = [s.id for s in db.session.query(Slice.id).all()]
self.login(username="admin")
uri = f"api/v1/chart/favorite_status/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
for res in data["result"]:
if res["id"] in users_favorite_ids:
assert res["value"]
@pytest.mark.usefixtures("load_unicode_dashboard_with_slice") @pytest.mark.usefixtures("load_unicode_dashboard_with_slice")
def test_get_charts_page(self): def test_get_charts_page(self):
""" """

View File

@ -31,7 +31,7 @@ from freezegun import freeze_time
from sqlalchemy import and_ from sqlalchemy import and_
from superset import db, security_manager from superset import db, security_manager
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.core import FavStar from superset.models.core import FavStar, FavStarClassName
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.views.base import generate_download_headers from superset.views.base import generate_download_headers
@ -340,6 +340,35 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
expected_model.dashboard_title == data["result"][i]["dashboard_title"] expected_model.dashboard_title == data["result"][i]["dashboard_title"]
) )
@pytest.mark.usefixtures("create_dashboards")
def test_get_current_user_favorite_status(self):
"""
Dataset API: Test get current user favorite stars
"""
admin = self.get_user("admin")
users_favorite_ids = [
star.obj_id
for star in db.session.query(FavStar.obj_id)
.filter(
and_(
FavStar.user_id == admin.id,
FavStar.class_name == FavStarClassName.DASHBOARD,
)
)
.all()
]
assert users_favorite_ids
arguments = [dash.id for dash in db.session.query(Dashboard.id).all()]
self.login(username="admin")
uri = f"api/v1/dashboard/favorite_status/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
for res in data["result"]:
if res["id"] in users_favorite_ids:
assert res["value"]
@pytest.mark.usefixtures("create_dashboards") @pytest.mark.usefixtures("create_dashboards")
def test_get_dashboards_not_favorite_filter(self): def test_get_dashboards_not_favorite_filter(self):
""" """