Stars: Refactor StarsToolbarButton and unify nav update logic (#112582)

This commit is contained in:
Tom Ratcliffe
2025-10-21 12:55:31 +01:00
committed by GitHub
parent 11235e7153
commit 8ff11b57b3
20 changed files with 454 additions and 163 deletions
@@ -0,0 +1,8 @@
import { setupMockStarredDashboards } from './starred';
/**
* Reset any stateful fixtures that are used to drive mock handler endpoints
*/
export const resetFixtures = () => {
setupMockStarredDashboards();
};
@@ -0,0 +1,14 @@
import { wellFormedTree } from './folders';
const [_, { folderA_dashbdD, dashbdD }] = wellFormedTree();
const initialStarredDashboards = [dashbdD.item.uid, folderA_dashbdD.item.uid];
export const setupMockStarredDashboards = () => {
mockStarredDashboardsMap.clear();
initialStarredDashboards.forEach((uid) => {
mockStarredDashboardsMap.set(uid, true);
});
};
export const mockStarredDashboardsMap = new Map<string, boolean>();
@@ -8,6 +8,7 @@ import userHandlers from './api/user/handlers';
import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers';
import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers';
import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers';
import appPlatformPreferencesv1alpha1Handlers from './apis/preferences.grafana.app/v1alpha1/handlers';
const allHandlers: HttpHandler[] = [
// Legacy handlers
@@ -21,6 +22,7 @@ const allHandlers: HttpHandler[] = [
...appPlatformDashboardv0alpha1Handlers,
...appPlatformFolderv1beta1Handlers,
...appPlatformIamv0alpha1Handlers,
...appPlatformPreferencesv1alpha1Handlers,
];
export default allHandlers;
@@ -2,7 +2,7 @@ import { Chance } from 'chance';
import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders';
import { mockStarredDashboards } from '../user/handlers';
import { mockStarredDashboardsMap } from '../../../fixtures/starred';
import { SORT_OPTIONS } from './constants';
@@ -42,7 +42,7 @@ const getLegacySearchHandler = () =>
}
if (starredFilter) {
filters.push(({ item }) => mockStarredDashboards.includes(item.uid));
filters.push(({ item }) => mockStarredDashboardsMap.has(item.uid));
}
if (folderFilter && folderFilter !== 'general') {
@@ -1,23 +1,23 @@
import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders';
const [_, { folderA_dashbdD, dashbdD }] = wellFormedTree();
export const mockStarredDashboards = [dashbdD.item.uid, folderA_dashbdD.item.uid];
import { mockStarredDashboardsMap } from '../../../fixtures/starred';
const getStarsHandler = () =>
http.get('/api/user/stars', async () => {
return HttpResponse.json(mockStarredDashboards);
return HttpResponse.json(Array.from(mockStarredDashboardsMap.keys()));
});
const deleteDashboardStarHandler = () =>
http.delete('/api/user/stars/dashboard/uid/:uid', async () => {
http.delete<{ uid: string }>('/api/user/stars/dashboard/uid/:uid', async ({ params }) => {
const { uid } = params;
mockStarredDashboardsMap.delete(uid);
return HttpResponse.json({ message: 'Dashboard unstarred' });
});
const addDashboardStarHandler = () =>
http.post('/api/user/stars/dashboard/uid/:uid', async () => {
http.post<{ uid: string }>('/api/user/stars/dashboard/uid/:uid', async ({ params }) => {
const { uid } = params;
mockStarredDashboardsMap.set(uid, true);
return HttpResponse.json({ message: 'Dashboard starred!' });
});
@@ -0,0 +1,69 @@
import { HttpResponse, http } from 'msw';
import { mockStarredDashboardsMap } from '../../../../fixtures/starred';
const getStarsHandler = () =>
http.get('/apis/preferences.grafana.app/v1alpha1/namespaces/:namespace/stars', () => {
const mockStarsResponse = {
kind: 'StarsList',
apiVersion: 'preferences.grafana.app/v1alpha1',
metadata: {
resourceVersion: '1758126936000',
},
items: [
{
metadata: {
name: 'user-u000000001',
namespace: 'default',
resourceVersion: '1758126936000',
creationTimestamp: '2025-05-14T14:02:10Z',
},
spec: {
resource: [
{
group: 'dashboard.grafana.app',
kind: 'Dashboard',
names: Array.from(mockStarredDashboardsMap.keys()),
},
],
},
status: {},
},
],
};
return HttpResponse.json(mockStarsResponse);
});
const UPDATE_STARS_URL =
'/apis/preferences.grafana.app/v1alpha1/namespaces/:namespace/stars/:name/update/:group/:kind/:id';
type UpdateOrDeleteStarsParams = {
namespace: string;
name: string;
group: string;
kind: string;
id: string;
};
const successResponse = {
kind: 'Status',
apiVersion: 'v1',
metadata: {},
code: 200,
};
const addStarHandler = () =>
http.put<UpdateOrDeleteStarsParams>(UPDATE_STARS_URL, ({ params }) => {
const { id } = params;
mockStarredDashboardsMap.set(id, true);
return HttpResponse.json(successResponse);
});
const removeStarHandler = () =>
http.delete<UpdateOrDeleteStarsParams>(UPDATE_STARS_URL, ({ params }) => {
const { id } = params;
mockStarredDashboardsMap.delete(id);
return HttpResponse.json(successResponse);
});
export default [getStarsHandler(), removeStarHandler(), addStarHandler()];
@@ -1,6 +1,7 @@
import { HttpHandler } from 'msw';
import { setupServer } from 'msw/node';
import { resetFixtures } from '../fixtures';
import allHandlers from '../handlers/all-handlers';
const server = setupServer(...allHandlers);
@@ -20,6 +21,10 @@ export function setupMockServer(
});
}
beforeEach(() => {
resetFixtures();
});
afterEach(() => {
server.resetHandlers();
});
+25
View File
@@ -0,0 +1,25 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { createBaseQuery } from 'app/api/createBaseQuery';
export const legacyUserAPI = createApi({
reducerPath: 'legacyUserAPI',
baseQuery: createBaseQuery({ baseURL: '/api' }),
tagTypes: ['dashboardStars'],
endpoints: (build) => ({
getStars: build.query<string[], void>({
query: () => ({ url: '/user/stars' }),
providesTags: ['dashboardStars'],
}),
starDashboard: build.mutation<void, { id: string }>({
query: ({ id }) => ({ url: `/user/stars/dashboard/uid/${id}`, method: 'POST' }),
invalidatesTags: ['dashboardStars'],
}),
unstarDashboard: build.mutation<void, { id: string }>({
query: ({ id }) => ({ url: `/user/stars/dashboard/uid/${id}`, method: 'DELETE' }),
invalidatesTags: ['dashboardStars'],
}),
}),
});
export const { useGetStarsQuery, useStarDashboardMutation, useUnstarDashboardMutation } = legacyUserAPI;
+2
View File
@@ -5,6 +5,7 @@ import { notificationsAPIv0alpha1, rulesAPIv0alpha1 } from '@grafana/alerting/un
import { dashboardAPIv0alpha1 } from 'app/api/clients/dashboard/v0alpha1';
import { preferencesAPIv1alpha1 } from 'app/api/clients/preferences/v1alpha1';
import { shortURLAPIv1alpha1 } from 'app/api/clients/shorturl/v1alpha1';
import { legacyUserAPI } from 'app/api/legacy/user/api';
import sharedReducers from 'app/core/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
@@ -64,6 +65,7 @@ const rootReducers = {
...authConfigReducers,
plugins: pluginsReducer,
[alertingApi.reducerPath]: alertingApi.reducer,
[legacyUserAPI.reducerPath]: legacyUserAPI.reducer,
[notificationsAPIv0alpha1.reducerPath]: notificationsAPIv0alpha1.reducer,
[rulesAPIv0alpha1.reducerPath]: rulesAPIv0alpha1.reducer,
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
@@ -7,8 +7,10 @@ import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { isProvisionedFolderCheck } from 'app/api/clients/folder/v1beta1/utils';
import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery';
import { legacyUserAPI } from 'app/api/legacy/user/api';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
import { setStarred } from 'app/core/reducers/navBarTree';
import { AnnoKeyFolder, Resource, ResourceList } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isDashboardV2Resource, isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils';
@@ -168,6 +170,7 @@ export const browseDashboardsAPI = createApi({
// delete an *individual* folder. used in the folder actions menu.
deleteFolder: builder.mutation<void, FolderDTO>({
invalidatesTags: ['getFolder'],
query: ({ uid }) => ({
url: `/folders/${uid}`,
method: 'DELETE',
@@ -339,7 +342,7 @@ export const browseDashboardsAPI = createApi({
// delete *multiple* dashboards. used in the delete modal.
deleteDashboards: builder.mutation<void, DeleteDashboardsArgs>({
invalidatesTags: ['getFolder'],
queryFn: async ({ dashboardUIDs }, _api, _extraOptions, baseQuery) => {
queryFn: async ({ dashboardUIDs }) => {
const pageStateManager = getDashboardScenePageStateManager();
// Delete all the dashboards sequentially
// TODO error handling here
@@ -375,9 +378,21 @@ export const browseDashboardsAPI = createApi({
}
return { data: undefined };
},
onQueryStarted: ({ dashboardUIDs }, { queryFulfilled, dispatch }) => {
onQueryStarted: ({ dashboardUIDs }, { queryFulfilled, getState }) => {
queryFulfilled.then(() => {
dispatch(refreshParents(dashboardUIDs));
dispatch(legacyUserAPI.util.invalidateTags(['dashboardStars']));
for (const uid of dashboardUIDs) {
dispatch(
setStarred({
id: uid,
// We don't need to send the title or url as we're removing the starred items here
title: '',
url: '',
isStarred: false,
})
);
}
});
},
}),
@@ -66,7 +66,7 @@ NavToolbarActions.displayName = 'NavToolbarActions';
* This part is split into a separate component to help test this
*/
export function ToolbarActions({ dashboard }: Props) {
const { isEditing, viewPanel, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState();
const { isEditing, viewPanel, isDirty, uid, meta, editview, editPanel, editable, title } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false);
@@ -101,12 +101,16 @@ export function ToolbarActions({ dashboard }: Props) {
group: 'icon-actions',
condition: uid && Boolean(meta.canStar) && isShowingDashboard && !isEditing,
render: () => {
if (!uid) {
return null;
}
return (
<StarToolbarButton
key="star-dashboard-button"
group="dashboard.grafana.app"
kind="Dashboard"
dashboard={dashboard}
title={title}
id={uid}
/>
);
},
@@ -3,5 +3,10 @@ import { StarToolbarButton } from 'app/features/stars/StarToolbarButton';
import { ToolbarActionProps } from '../types';
export const StarButton = ({ dashboard }: ToolbarActionProps) => {
return <StarToolbarButton group="dashboard.grafana.app" kind="Dashboard" dashboard={dashboard} />;
const { uid, title } = dashboard.useState();
if (!uid) {
return null;
}
return <StarToolbarButton group="dashboard.grafana.app" kind="Dashboard" id={uid} title={title} />;
};
@@ -4,6 +4,7 @@ import { DataFrame, DataFrameView, getDisplayProcessor, SelectableValue, toDataF
import { t } from '@grafana/i18n';
import { config, getBackendSrv } from '@grafana/runtime';
import { generatedAPI, ListStarsApiResponse } from 'app/api/clients/preferences/v1alpha1';
import { legacyUserAPI } from 'app/api/legacy/user/api';
import { getAPIBaseURL } from 'app/api/utils';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { contextSrv } from 'app/core/core';
@@ -89,7 +90,7 @@ export class UnifiedSearcher implements GrafanaSearcher {
(info) => info.group === 'dashboard.grafana.app' && info.kind === 'Dashboard'
)?.names || [];
} else {
starsIds = await getBackendSrv().get('api/user/stars');
starsIds = await dispatch(legacyUserAPI.endpoints.getStars.initiate()).unwrap();
}
if (starsIds?.length) {
@@ -0,0 +1,114 @@
import { render, screen, testWithFeatureToggles } from 'test/test-utils';
import { GrafanaConfig, locationUtil } from '@grafana/data';
import { config, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { useSelector } from 'app/types/store';
import { StarToolbarButton } from './StarToolbarButton';
const [_, { dashbdD, folderA_folderB_dashbdB }] = getFolderFixtures();
setBackendSrv(backendSrv);
setupMockServer();
locationUtil.initialize({
config: { appSubUrl: '/foo/bar' } as GrafanaConfig,
getTimeRangeForUrl: jest.fn(),
getVariablesUrlParams: jest.fn(),
});
/**
* Test component that renders the list of starred items from the nav tree state
*
* This is just for basic assertions that we've added/removed items correctly
*/
const TestStarredMenuItems = () => {
const navTree = useSelector((state) => state.navBarTree);
const starred = navTree.find((item) => item.id === 'starred');
return (
<div>
{starred?.children?.map((child) => (
<a key={child.id} href={child.url} data-testid={`starred-item-${child.text}`}>
{child.text}
</a>
))}
</div>
);
};
const existingStarredItem = dashbdD.item;
const itemToStar = folderA_folderB_dashbdB.item;
const findStarButton = (title: string, isStarred: boolean) =>
screen.findByRole('button', { name: new RegExp(`^${isStarred ? 'unmark' : 'mark'} "${title}" as favorite`, 'i') });
const setup = (dashboardForStarButton: typeof existingStarredItem | typeof itemToStar) => {
config.bootData.navTree = [
{
id: 'starred',
text: 'Starred',
children: [
{
text: existingStarredItem.title,
id: `starred/${existingStarredItem.uid}`,
url: existingStarredItem.url,
},
],
},
];
return render(
<>
<TestStarredMenuItems />
<StarToolbarButton
title={dashboardForStarButton.title}
group="dashboard.grafana.app"
kind="Dashboard"
id={dashboardForStarButton.uid}
/>
</>
);
};
const fixtures: Array<
[
// Test title
string,
// Feature toggle setup
Parameters<typeof testWithFeatureToggles>[0],
]
> = [
['app platform APIs enabled', { enable: ['starsFromAPIServer'] }],
['app platform APIs disabled', {}],
];
describe('StarToolbarButton', () => {
describe.each(fixtures)('%s', (_title, featureToggleSetup) => {
testWithFeatureToggles(featureToggleSetup);
it('adds a nav menu item, including correct url', async () => {
const { user } = setup(itemToStar);
const expectedTestId = `starred-item-${itemToStar.title}`;
expect(screen.queryByTestId(expectedTestId)).not.toBeInTheDocument();
await user.click(await findStarButton(itemToStar.title, false));
const navItem = await screen.findByTestId(expectedTestId);
expect(navItem).toBeInTheDocument();
expect(navItem).toHaveAttribute('href', `/foo/bar/d/${itemToStar.uid}`);
});
it('removes a nav menu item', async () => {
const { user } = setup(existingStarredItem);
const expectedTestId = `starred-item-${existingStarredItem.title}`;
expect(await screen.findByTestId(expectedTestId)).toBeInTheDocument();
await user.click(await findStarButton(existingStarredItem.title, true));
expect(screen.queryByTestId(expectedTestId)).not.toBeInTheDocument();
});
});
});
+44 -82
View File
@@ -1,109 +1,71 @@
import { useMemo } from 'react';
import { useAsyncRetry } from 'react-use';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { config, getBackendSrv } from '@grafana/runtime';
import { Icon, ToolbarButton } from '@grafana/ui';
import { useAddStarMutation, useRemoveStarMutation, useListStarsQuery } from 'app/api/clients/preferences/v1alpha1';
import { contextSrv } from 'app/core/core';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { DashboardScene } from '../dashboard-scene/scene/DashboardScene';
import { useStarItem, useStarredItems } from './hooks';
const getStarTooltips = () => ({
star: t('dashboard.toolbar.mark-favorite', 'Mark as favorite'),
unstar: t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite'),
const getStarTooltips = (title: string) => ({
star: t('stars.mark-as-starred', 'Mark "{{title}}" as favorite', {
title,
}),
unstar: t('stars.unmark-as-starred', 'Unmark "{{title}}" as favorite', {
title,
}),
});
export type Props = {
type Props = {
title: string;
group: string;
kind: string;
dashboard: DashboardScene;
id: string;
onStarChange?: (id: string, isStarred: boolean) => void;
};
export function StarToolbarButtonApiServer({ group, kind, id }: Pick<Props, 'group' | 'kind'> & { id: string }) {
const name = `user-${contextSrv.user.uid}`;
const stars = useListStarsQuery({ fieldSelector: `metadata.name=${name}` });
const [addStar] = useAddStarMutation();
const [removeStar] = useRemoveStarMutation();
export function StarToolbarButton({ title, group, kind, id, onStarChange }: Props) {
const tooltips = getStarTooltips(title);
const handleItemStar = useStarItem(group, kind);
const { data: stars, isLoading } = useStarredItems(group, kind);
const isStarred = useMemo(() => {
const starredItems = stars.data?.items || [];
if (!starredItems.length) {
return false;
}
const matchingInfo = starredItems[0]?.spec.resource.find((info) => info.group === group && info.kind === kind);
return matchingInfo ? matchingInfo.names.includes(id) : false;
}, [stars.data?.items, id, group, kind]);
const starredItems = stars || [];
const handleStarToggle = () => {
const mutationArgs = { name, group, kind, id };
if (isStarred) {
removeStar(mutationArgs);
} else {
addStar(mutationArgs);
}
return starredItems.includes(id);
}, [id, stars]);
const handleStarToggle = async () => {
await handleItemStar({ id, title }, !isStarred);
onStarChange?.(id, !isStarred);
};
// Do not render the icon until data is loaded to make sure correct icon is displayed
if (stars.isLoading) {
return null;
}
const iconProps = (() => {
if (isLoading) {
return { name: 'spinner', type: 'default' } as const;
}
if (isStarred) {
return { name: 'favorite', type: 'mono' } as const;
}
return { name: 'star', type: 'default' } as const;
})();
const tooltips = getStarTooltips();
const tooltip = (() => {
if (isLoading) {
return undefined;
}
return isStarred ? tooltips.unstar : tooltips.star;
})();
const icon = <Icon {...iconProps} size="lg" />;
return (
<ToolbarButton
tooltip={isStarred ? tooltips.unstar : tooltips.star}
icon={<Icon name={isStarred ? 'favorite' : 'star'} size="lg" type={isStarred ? 'mono' : 'default'} />}
disabled={isLoading}
tooltip={tooltip}
icon={icon}
data-testid={selectors.components.NavToolbar.markAsFavorite}
onClick={handleStarToggle}
/>
);
}
function StarToolbarButtonLegacy({ dashboard }: { dashboard: Props['dashboard'] }) {
const { meta, uid: uidFromState } = dashboard.useState();
// uidFromState is used for legacy dashboards (kubernetesDashboards toggle is off)
const uid = meta.uid || meta.k8s?.name || uidFromState;
const tooltips = getStarTooltips();
const { value: starredUids, retry } = useAsyncRetry(async () => {
return getBackendSrv().get('api/user/stars');
});
if (!starredUids || !uid) {
return null;
}
const isStarred = starredUids?.includes(uid);
return (
<ToolbarButton
tooltip={isStarred ? tooltips.unstar : tooltips.star}
icon={<Icon name={isStarred ? 'favorite' : 'star'} size="lg" type={isStarred ? 'mono' : 'default'} />}
data-testid={selectors.components.NavToolbar.markAsFavorite}
onClick={async () => {
DashboardInteractions.toolbarFavoritesClick();
await dashboard.onStarDashboard(isStarred);
retry();
}}
/>
);
}
export function StarToolbarButton({ dashboard, group, kind }: Props) {
const state = dashboard.useState();
// In legacy storage dashboard uid is stored in state.uid
const uid = state.meta.uid || state.uid;
if (!contextSrv.user.uid || !uid?.length) {
return null;
}
if (config.featureToggles.starsFromAPIServer) {
return <StarToolbarButtonApiServer group={group} kind={kind} id={uid} />;
}
return <StarToolbarButtonLegacy dashboard={dashboard} />;
}
+94
View File
@@ -0,0 +1,94 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useMemo } from 'react';
import { locationUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useAddStarMutation, useRemoveStarMutation, useListStarsQuery } from 'app/api/clients/preferences/v1alpha1';
import {
useGetStarsQuery as useLegacyGetStarsQuery,
useStarDashboardMutation as useLegacyStarDashboardMutation,
useUnstarDashboardMutation as useLegacyUnstarDashboardMutation,
} from 'app/api/legacy/user/api';
import { contextSrv } from 'app/core/core';
import { setStarred } from 'app/core/reducers/navBarTree';
import { dispatch } from 'app/store/store';
type StarItemArgs = {
id: string;
/** Title of the item - this is displayed in the nav */
title: string;
};
/** Star or unstar an item */
export const useStarItem = (group: string, kind: string) => {
const [addStar] = useAddStarMutation();
const [removeStar] = useRemoveStarMutation();
const [addStarLegacy] = useLegacyStarDashboardMutation();
const [removeStarLegacy] = useLegacyUnstarDashboardMutation();
const updateStarred = useUpdateNavStarredItems();
if (config.featureToggles.starsFromAPIServer) {
return async ({ id, title }: StarItemArgs, newStarredState: boolean) => {
const name = `user-${contextSrv.user.uid}`;
const mutationArgs = { id, name, group, kind };
if (newStarredState) {
await addStar(mutationArgs);
} else {
await removeStar(mutationArgs);
}
updateStarred({ id, title }, newStarredState);
};
}
return async ({ id, title }: StarItemArgs, newStarredState: boolean) => {
if (newStarredState) {
await addStarLegacy({ id });
} else {
await removeStarLegacy({ id });
}
updateStarred({ id, title }, newStarredState);
};
};
/**
* Get starred items from legacy or app platform API
*/
export const useStarredItems = (group: string, kind: string) => {
const name = `user-${contextSrv.user.uid}`;
const appPlatform = config.featureToggles.starsFromAPIServer;
const legacyResponse = useLegacyGetStarsQuery(appPlatform ? skipToken : undefined);
const appPlatformResponse = useListStarsQuery(!appPlatform ? skipToken : { fieldSelector: `metadata.name=${name}` });
const appPlatformStarredItems = useMemo(() => {
const { data } = appPlatformResponse;
if (data) {
const starredItems = appPlatformResponse.data?.items || [];
if (!starredItems.length) {
return [];
}
return starredItems[0]?.spec.resource.find((info) => info.group === group && info.kind === kind)?.names || [];
}
return undefined;
}, [appPlatformResponse, group, kind]);
return appPlatform
? {
...appPlatformResponse,
data: appPlatformStarredItems,
}
: legacyResponse;
};
/**
* Hook to update the nav menu with starred items
*/
export const useUpdateNavStarredItems = () => {
return ({ id, title }: { id: string; title: string }, isStarred: boolean) => {
const url = locationUtil.assureBaseUrl(`/d/${id}`);
return dispatch(setStarred({ id, title, url, isStarred }));
};
};
@@ -32,13 +32,19 @@ const defaultOptions: Options = {
const findStarButton = (title: string, isStarred: boolean) =>
screen.findByRole('button', { name: new RegExp(`^${isStarred ? 'unmark' : 'mark'} "${title}" as favorite`, 'i') });
describe.each([
// App platform APIs
true,
// Legacy APIs
false,
])('DashList - app platform APIs: %s', (featureTogglesEnabled) => {
testWithFeatureToggles({ enable: featureTogglesEnabled ? ['unifiedStorageSearchUI'] : [] });
const fixtures: Array<
[
// Test title
string,
// Feature toggle setup
Parameters<typeof testWithFeatureToggles>[0],
]
> = [
['DashList - app platform APIs enabled', { enable: ['unifiedStorageSearchUI', 'starsFromAPIServer'] }],
['DashList - app platform APIs disabled', {}],
];
describe.each(fixtures)('%s', (_title, featureTogglesSetup) => {
testWithFeatureToggles(featureTogglesSetup);
it('renders different groups of dashboards', async () => {
const props = getPanelProps({
+16 -55
View File
@@ -1,20 +1,15 @@
import { take } from 'lodash';
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useThrottle } from 'react-use';
import { InterpolateFunction, PanelProps, textUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useStyles2, IconButton, ScrollContainer, Box, Text, EmptyState, Link } from '@grafana/ui';
import { useStyles2, ScrollContainer, Box, Text, EmptyState, Link } from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree';
import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel';
import impressionSrv from 'app/core/services/impression_srv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult, LocationInfo, QueryResponse, SearchQuery } from 'app/features/search/service/types';
import { StarToolbarButtonApiServer } from 'app/features/stars/StarToolbarButton';
import { useDispatch, useSelector } from 'app/types/store';
import { StarToolbarButton } from 'app/features/stars/StarToolbarButton';
import { Options } from './panelcfg.gen';
import { getStyles } from './styles';
@@ -121,8 +116,6 @@ const collator = new Intl.Collator();
export function DashList(props: PanelProps<Options>) {
const [dashboards, setDashboards] = useState(new Map<string, Dashboard>());
const [foldersTitleMap, setFoldersTitleMap] = useState<Record<string, LocationInfo>>({});
const dispatch = useDispatch();
const navIndex = useSelector((state) => state.navIndex);
const throttledRenderCount = useThrottle(props.renderCounter, 5000);
@@ -140,35 +133,6 @@ export function DashList(props: PanelProps<Options>) {
}
}, [props.options.showFolderNames, dashboards]);
const toggleDashboardStar = async (e: SyntheticEvent, dash: Dashboard) => {
const { uid, name, url } = dash;
e.preventDefault();
e.stopPropagation();
const isStarred = await getDashboardSrv().starDashboard(dash.uid, Boolean(dash.isStarred));
const updatedDashboards = new Map(dashboards);
updatedDashboards.set(dash?.uid ?? '', { ...dash, isStarred });
setDashboards(updatedDashboards);
dispatch(setStarred({ id: uid ?? '', title: name, url, isStarred }));
const starredNavItem = navIndex.starred;
if (isStarred) {
starredNavItem.children?.push({
id: ID_PREFIX + uid,
text: name,
url: url ?? '',
parentItem: starredNavItem,
});
} else {
dispatch(removeNavIndex(ID_PREFIX + uid));
const indexToRemove = starredNavItem.children?.findIndex((element) => element.id === ID_PREFIX + uid);
if (indexToRemove) {
starredNavItem.children?.splice(indexToRemove, 1);
}
}
dispatch(updateNavIndex(starredNavItem));
};
const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => {
const dashboardList = [...dashboards.values()];
const dashboardsGroupsMap: Record<string, Dashboard[]> = {
@@ -215,6 +179,12 @@ export function DashList(props: PanelProps<Options>) {
},
];
const handleStarChange = (id: string, isStarred: boolean) => {
const updatedDashboards = new Map(dashboards);
updatedDashboards.set(id, { ...dashboards.get(id)!, isStarred });
setDashboards(updatedDashboards);
};
const css = useStyles2(getStyles);
const urlParams = useDashListUrlParams(props);
@@ -223,12 +193,6 @@ export function DashList(props: PanelProps<Options>) {
{dashboards.map((dash) => {
let url = dash.url + urlParams;
url = getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url);
const markAsStarredText = t('panel.dashlist.mark-as-starred', 'Mark "{{title}}" as favorite', {
title: dash.title,
});
const unmarkAsStarredText = t('panel.dashlist.unmark-as-starred', 'Unmark "{{title}}" as favorite', {
title: dash.title,
});
const locationInfo = showFolderNames && dash.location ? foldersTitleMap[dash.location] : undefined;
return (
@@ -242,16 +206,13 @@ export function DashList(props: PanelProps<Options>) {
</Text>
)}
</Box>
{config.featureToggles.starsFromAPIServer ? (
<StarToolbarButtonApiServer group="dashboard.grafana.app" kind="Dashboard" id={dash.uid ?? ''} />
) : (
<IconButton
tooltip={dash.isStarred ? unmarkAsStarredText : markAsStarredText}
name={dash.isStarred ? 'favorite' : 'star'}
iconType={dash.isStarred ? 'mono' : 'default'}
onClick={(e) => toggleDashboardStar(e, dash)}
/>
)}
<StarToolbarButton
title={dash.name}
group="dashboard.grafana.app"
kind="Dashboard"
id={dash.uid}
onStarChange={handleStarChange}
/>
</div>
</li>
);
+2
View File
@@ -6,6 +6,7 @@ import { notificationsAPIv0alpha1, rulesAPIv0alpha1 } from '@grafana/alerting/un
import { dashboardAPIv0alpha1 } from 'app/api/clients/dashboard/v0alpha1';
import { preferencesAPIv1alpha1 } from 'app/api/clients/preferences/v1alpha1';
import { shortURLAPIv1alpha1 } from 'app/api/clients/shorturl/v1alpha1';
import { legacyUserAPI } from 'app/api/legacy/user/api';
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
import { cloudMigrationAPI } from 'app/features/migrate-to-cloud/api';
@@ -53,6 +54,7 @@ export function configureStore(initialState?: Partial<StoreState>) {
// other Grafana core APIs
publicDashboardApi.middleware,
browseDashboardsAPI.middleware,
legacyUserAPI.middleware,
cloudMigrationAPI.middleware,
userPreferencesAPI.middleware,
iamAPIv0alpha1.middleware,
+5 -3
View File
@@ -10757,11 +10757,9 @@
"panel": {
"dashlist": {
"empty-state-message": "No dashboard groups configured",
"mark-as-starred": "Mark \"{{title}}\" as favorite",
"recently-viewed-dashboards": "Recently viewed dashboards",
"search": "Search",
"starred-dashboards": "Starred dashboards",
"unmark-as-starred": "Unmark \"{{title}}\" as favorite"
"starred-dashboards": "Starred dashboards"
},
"get-calculation-value-data-links-variable-suggestions": {
"value-calc-var": {
@@ -12715,6 +12713,10 @@
"suggestions": "Suggestions",
"view-explanation": "View explanation"
},
"stars": {
"mark-as-starred": "Mark \"{{title}}\" as favorite",
"unmark-as-starred": "Unmark \"{{title}}\" as favorite"
},
"stat": {
"add-orientation-option": {
"description-orientation": "Layout orientation",