From 8ff11b57b330a42a4440dba88ce57279bf6f3a48 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Tue, 21 Oct 2025 12:55:31 +0100 Subject: [PATCH] Stars: Refactor StarsToolbarButton and unify nav update logic (#112582) --- .../grafana-test-utils/src/fixtures/index.ts | 8 ++ .../src/fixtures/starred.ts | 14 ++ .../src/handlers/all-handlers.ts | 2 + .../src/handlers/api/search/handlers.ts | 4 +- .../src/handlers/api/user/handlers.ts | 16 +-- .../v1alpha1/handlers.ts | 69 ++++++++++ .../grafana-test-utils/src/server/index.ts | 5 + public/app/api/legacy/user/api.ts | 25 ++++ public/app/core/reducers/root.ts | 2 + .../api/browseDashboardsAPI.ts | 19 ++- .../scene/NavToolbarActions.tsx | 8 +- .../scene/new-toolbar/actions/StarButton.tsx | 7 +- public/app/features/search/service/unified.ts | 3 +- .../features/stars/StarToolbarButton.test.tsx | 114 ++++++++++++++++ .../app/features/stars/StarToolbarButton.tsx | 126 ++++++------------ public/app/features/stars/hooks.ts | 94 +++++++++++++ .../plugins/panel/dashlist/DashList.test.tsx | 20 ++- .../app/plugins/panel/dashlist/DashList.tsx | 71 +++------- public/app/store/configureStore.ts | 2 + public/locales/en-US/grafana.json | 8 +- 20 files changed, 454 insertions(+), 163 deletions(-) create mode 100644 packages/grafana-test-utils/src/fixtures/index.ts create mode 100644 packages/grafana-test-utils/src/fixtures/starred.ts create mode 100644 packages/grafana-test-utils/src/handlers/apis/preferences.grafana.app/v1alpha1/handlers.ts create mode 100644 public/app/api/legacy/user/api.ts create mode 100644 public/app/features/stars/StarToolbarButton.test.tsx create mode 100644 public/app/features/stars/hooks.ts diff --git a/packages/grafana-test-utils/src/fixtures/index.ts b/packages/grafana-test-utils/src/fixtures/index.ts new file mode 100644 index 00000000000..0fc6300ba49 --- /dev/null +++ b/packages/grafana-test-utils/src/fixtures/index.ts @@ -0,0 +1,8 @@ +import { setupMockStarredDashboards } from './starred'; + +/** + * Reset any stateful fixtures that are used to drive mock handler endpoints + */ +export const resetFixtures = () => { + setupMockStarredDashboards(); +}; diff --git a/packages/grafana-test-utils/src/fixtures/starred.ts b/packages/grafana-test-utils/src/fixtures/starred.ts new file mode 100644 index 00000000000..77150e54c4a --- /dev/null +++ b/packages/grafana-test-utils/src/fixtures/starred.ts @@ -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(); diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 3b1c7eb7017..58e94fe83ac 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -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; diff --git a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts index ed42d326d46..2aebd03b174 100644 --- a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts @@ -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') { diff --git a/packages/grafana-test-utils/src/handlers/api/user/handlers.ts b/packages/grafana-test-utils/src/handlers/api/user/handlers.ts index 90a7ebe049a..b426f6d0733 100644 --- a/packages/grafana-test-utils/src/handlers/api/user/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/user/handlers.ts @@ -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!' }); }); diff --git a/packages/grafana-test-utils/src/handlers/apis/preferences.grafana.app/v1alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/preferences.grafana.app/v1alpha1/handlers.ts new file mode 100644 index 00000000000..e5ad85df839 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/preferences.grafana.app/v1alpha1/handlers.ts @@ -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(UPDATE_STARS_URL, ({ params }) => { + const { id } = params; + mockStarredDashboardsMap.set(id, true); + return HttpResponse.json(successResponse); + }); + +const removeStarHandler = () => + http.delete(UPDATE_STARS_URL, ({ params }) => { + const { id } = params; + mockStarredDashboardsMap.delete(id); + return HttpResponse.json(successResponse); + }); + +export default [getStarsHandler(), removeStarHandler(), addStarHandler()]; diff --git a/packages/grafana-test-utils/src/server/index.ts b/packages/grafana-test-utils/src/server/index.ts index 5b0ba7b0d83..71636cda7ac 100644 --- a/packages/grafana-test-utils/src/server/index.ts +++ b/packages/grafana-test-utils/src/server/index.ts @@ -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(); }); diff --git a/public/app/api/legacy/user/api.ts b/public/app/api/legacy/user/api.ts new file mode 100644 index 00000000000..c7f110db504 --- /dev/null +++ b/public/app/api/legacy/user/api.ts @@ -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({ + query: () => ({ url: '/user/stars' }), + providesTags: ['dashboardStars'], + }), + starDashboard: build.mutation({ + query: ({ id }) => ({ url: `/user/stars/dashboard/uid/${id}`, method: 'POST' }), + invalidatesTags: ['dashboardStars'], + }), + unstarDashboard: build.mutation({ + query: ({ id }) => ({ url: `/user/stars/dashboard/uid/${id}`, method: 'DELETE' }), + invalidatesTags: ['dashboardStars'], + }), + }), +}); + +export const { useGetStarsQuery, useStarDashboardMutation, useUnstarDashboardMutation } = legacyUserAPI; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 82182101493..41e11265c09 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -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, diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index d1789e54e21..d884e47fe36 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -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({ + 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({ 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, + }) + ); + } }); }, }), diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 4e8caac3bc6..c1ab00abf92 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -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 ( ); }, diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/StarButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/StarButton.tsx index 2fdef303422..668e45bfe42 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/StarButton.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/StarButton.tsx @@ -3,5 +3,10 @@ import { StarToolbarButton } from 'app/features/stars/StarToolbarButton'; import { ToolbarActionProps } from '../types'; export const StarButton = ({ dashboard }: ToolbarActionProps) => { - return ; + const { uid, title } = dashboard.useState(); + if (!uid) { + return null; + } + + return ; }; diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 8328c753700..d363c7f2068 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -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) { diff --git a/public/app/features/stars/StarToolbarButton.test.tsx b/public/app/features/stars/StarToolbarButton.test.tsx new file mode 100644 index 00000000000..2c3e2e7468d --- /dev/null +++ b/public/app/features/stars/StarToolbarButton.test.tsx @@ -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 ( +
+ {starred?.children?.map((child) => ( + + {child.text} + + ))} +
+ ); +}; + +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( + <> + + + + ); +}; + +const fixtures: Array< + [ + // Test title + string, + // Feature toggle setup + Parameters[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(); + }); + }); +}); diff --git a/public/app/features/stars/StarToolbarButton.tsx b/public/app/features/stars/StarToolbarButton.tsx index 3a9949ed42a..fe29cd9da0c 100644 --- a/public/app/features/stars/StarToolbarButton.tsx +++ b/public/app/features/stars/StarToolbarButton.tsx @@ -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 & { 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 = ; return ( } + 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 ( - } - 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 ; - } - - return ; -} diff --git a/public/app/features/stars/hooks.ts b/public/app/features/stars/hooks.ts new file mode 100644 index 00000000000..8b10fa989df --- /dev/null +++ b/public/app/features/stars/hooks.ts @@ -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 })); + }; +}; diff --git a/public/app/plugins/panel/dashlist/DashList.test.tsx b/public/app/plugins/panel/dashlist/DashList.test.tsx index 88f6032d3fc..339b84ae2f3 100644 --- a/public/app/plugins/panel/dashlist/DashList.test.tsx +++ b/public/app/plugins/panel/dashlist/DashList.test.tsx @@ -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[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({ diff --git a/public/app/plugins/panel/dashlist/DashList.tsx b/public/app/plugins/panel/dashlist/DashList.tsx index 76ee1497a9e..dab66730379 100644 --- a/public/app/plugins/panel/dashlist/DashList.tsx +++ b/public/app/plugins/panel/dashlist/DashList.tsx @@ -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) { const [dashboards, setDashboards] = useState(new Map()); const [foldersTitleMap, setFoldersTitleMap] = useState>({}); - const dispatch = useDispatch(); - const navIndex = useSelector((state) => state.navIndex); const throttledRenderCount = useThrottle(props.renderCounter, 5000); @@ -140,35 +133,6 @@ export function DashList(props: PanelProps) { } }, [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 = { @@ -215,6 +179,12 @@ export function DashList(props: PanelProps) { }, ]; + 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) { {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) { )} - {config.featureToggles.starsFromAPIServer ? ( - - ) : ( - toggleDashboardStar(e, dash)} - /> - )} + ); diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index b05dc7c7b67..ebe79de7cd6 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -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) { // other Grafana core APIs publicDashboardApi.middleware, browseDashboardsAPI.middleware, + legacyUserAPI.middleware, cloudMigrationAPI.middleware, userPreferencesAPI.middleware, iamAPIv0alpha1.middleware, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d85d332d402..794dab7bbf5 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",