Stars: Refactor StarsToolbarButton and unify nav update logic (#112582)
This commit is contained in:
@@ -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!' });
|
||||
});
|
||||
|
||||
|
||||
+69
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user