diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e2a6687ec69..6c79f660966 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -448,6 +448,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/features/transformers/timeSeriesTable/ @grafana/dataviz-squad @grafana/app-o11y-visualizations /public/app/features/users/ @grafana/access-squad /public/app/features/variables/ @grafana/dashboards-squad +/public/app/features/preferences/ @grafana/grafana-frontend-platform /public/app/plugins/panel/alertlist/ @grafana/alerting-frontend /public/app/plugins/panel/annolist/ @grafana/grafana-frontend-platform /public/app/plugins/panel/barchart/ @grafana/dataviz-squad diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx index ca58940a152..3b29a60fc20 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx @@ -1,16 +1,19 @@ import { css } from '@emotion/css'; import { DOMAttributes } from '@react-types/shared'; -import { memo, forwardRef } from 'react'; +import { memo, forwardRef, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { t } from 'app/core/internationalization'; +import { usePatchUserPreferencesMutation } from 'app/features/preferences/api/index'; import { useSelector } from 'app/types'; import { MegaMenuItem } from './MegaMenuItem'; +import { usePinnedItems } from './hooks'; import { enrichWithInteractionTracking, getActiveItem } from './utils'; export const MENU_WIDTH = '300px'; @@ -26,6 +29,8 @@ export const MegaMenu = memo( const location = useLocation(); const { chrome } = useGrafana(); const state = chrome.useState(); + const [patchPreferences] = usePatchUserPreferencesMutation(); + const pinnedItems = usePinnedItems(); // Remove profile + help from tree const navItems = navTree @@ -46,6 +51,29 @@ export const MegaMenu = memo( }); }; + const isPinned = useCallback( + (id?: string) => { + if (!id || !pinnedItems?.length) { + return false; + } + return pinnedItems?.includes(id); + }, + [pinnedItems] + ); + + const onPinItem = (id?: string) => { + if (id && config.featureToggles.pinNavItems) { + const newItems = isPinned(id) ? pinnedItems.filter((i) => id !== i) : [...pinnedItems, id]; + patchPreferences({ + patchPrefsCmd: { + navbar: { + savedItemIds: newItems, + }, + }, + }); + } + }; + return (
@@ -79,8 +107,10 @@ export const MegaMenu = memo( )} ))} diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx index aee471e961d..ee5d85cc97c 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx @@ -19,11 +19,13 @@ interface Props { activeItem?: NavModelItem; onClick?: () => void; level?: number; + onPin: (id?: string) => void; + isPinned: (id?: string) => boolean; } const MAX_DEPTH = 2; -export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { +export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPinned }: Props) { const { chrome } = useGrafana(); const state = chrome.useState(); const menuIsDocked = state.megaMenuDocked; @@ -102,6 +104,9 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { }} target={link.target} url={link.url} + id={link.id} + onPin={onPin} + isPinned={isPinned(link.id)} >
)) ) : ( diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx index c6d15f4ab95..573bf0190c0 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { Icon, Link, useTheme2 } from '@grafana/ui'; export interface Props { @@ -11,9 +12,12 @@ export interface Props { onClick?: () => void; target?: HTMLAnchorElement['target']; url: string; + id?: string; + onPin: (id?: string) => void; + isPinned?: boolean; } -export function MegaMenuItemText({ children, isActive, onClick, target, url }: Props) { +export function MegaMenuItemText({ children, isActive, onClick, target, url, id, onPin, isPinned }: Props) { const theme = useTheme2(); const styles = getStyles(theme, isActive); const LinkComponent = !target && url.startsWith('/') ? Link : 'a'; @@ -26,6 +30,17 @@ export function MegaMenuItemText({ children, isActive, onClick, target, url }: P // As nav links are supposed to link to internal urls this option should be used with caution target === '_blank' && } + {config.featureToggles.pinNavItems && ( + { + e.preventDefault(); + e.stopPropagation(); + onPin(id); + }} + /> + )}
); @@ -90,5 +105,17 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ gap: '0.5rem', height: '100%', width: '100%', + justifyContent: 'space-between', + '.pin-icon': { + display: 'none', + padding: theme.spacing(0.5), + width: theme.spacing(3), + height: theme.spacing(3), + }, + '&:hover': { + '.pin-icon': { + display: 'block', + }, + }, }), }); diff --git a/public/app/core/components/AppChrome/MegaMenu/hooks.ts b/public/app/core/components/AppChrome/MegaMenu/hooks.ts new file mode 100644 index 00000000000..0334df81fd0 --- /dev/null +++ b/public/app/core/components/AppChrome/MegaMenu/hooks.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; + +import { config } from '@grafana/runtime'; +import { useGetUserPreferencesQuery } from 'app/features/preferences/api'; + +export const usePinnedItems = () => { + const preferences = useGetUserPreferencesQuery(); + const pinnedItems = useMemo(() => preferences.data?.navbar?.savedItemIds || [], [preferences]); + + if (config.featureToggles.pinNavItems) { + return pinnedItems; + } + return []; +}; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index ec33626bc43..73b73b52d4d 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -28,6 +28,7 @@ import usersReducers from 'app/features/users/state/reducers'; import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; import { alertingApi } from '../../features/alerting/unified/api/alertingApi'; +import { userPreferencesAPI } from '../../features/preferences/api'; import { queryLibraryApi } from '../../features/query-library/api/factory'; import { cleanUpAction } from '../actions/cleanUp'; @@ -59,6 +60,7 @@ const rootReducers = { [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, [cloudMigrationAPI.reducerPath]: cloudMigrationAPI.reducer, [queryLibraryApi.reducerPath]: queryLibraryApi.reducer, + [userPreferencesAPI.reducerPath]: userPreferencesAPI.reducer, }; const addedReducers = {}; diff --git a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts index 537535a7920..cd533d124aa 100644 --- a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts +++ b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts @@ -143,7 +143,7 @@ export type ErrorResponseBody = { /** a human readable version of the error */ message: string; /** Status An optional status to denote the cause of the error. - + For example, a 412 Precondition Failed error may include additional information of why that error happened. */ status?: string; }; diff --git a/public/app/features/preferences/api/index.ts b/public/app/features/preferences/api/index.ts new file mode 100644 index 00000000000..1da53081140 --- /dev/null +++ b/public/app/features/preferences/api/index.ts @@ -0,0 +1,19 @@ +import { generatedAPI } from './user/endpoints.gen'; + +export const { useGetUserPreferencesQuery, usePatchUserPreferencesMutation, useUpdateUserPreferencesMutation } = + generatedAPI; + +export const userPreferencesAPI = generatedAPI.enhanceEndpoints({ + addTagTypes: ['UserPreferences'], + endpoints: { + getUserPreferences: { + providesTags: ['UserPreferences'], + }, + updateUserPreferences: { + invalidatesTags: ['UserPreferences'], + }, + patchUserPreferences: { + invalidatesTags: ['UserPreferences'], + }, + }, +}); diff --git a/public/app/features/preferences/api/user/baseAPI.ts b/public/app/features/preferences/api/user/baseAPI.ts new file mode 100644 index 00000000000..b1b75e7b036 --- /dev/null +++ b/public/app/features/preferences/api/user/baseAPI.ts @@ -0,0 +1,36 @@ +import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; +import { lastValueFrom } from 'rxjs'; + +import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; + +interface RequestOptions extends BackendSrvRequest { + manageError?: (err: unknown) => { error: unknown }; + showErrorAlert?: boolean; + body?: BackendSrvRequest['data']; +} + +function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn { + async function backendSrvBaseQuery(requestOptions: RequestOptions) { + try { + const { data: responseData, ...meta } = await lastValueFrom( + getBackendSrv().fetch({ + ...requestOptions, + url: baseURL + requestOptions.url, + showErrorAlert: requestOptions.showErrorAlert, + data: requestOptions.body, + }) + ); + return { data: responseData, meta }; + } catch (error) { + return requestOptions.manageError ? requestOptions.manageError(error) : { error }; + } + } + + return backendSrvBaseQuery; +} + +export const baseAPI = createApi({ + reducerPath: 'userPreferencesAPI', + baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), + endpoints: () => ({}), +}); diff --git a/public/app/features/preferences/api/user/endpoints.gen.ts b/public/app/features/preferences/api/user/endpoints.gen.ts new file mode 100644 index 00000000000..e5aff73929c --- /dev/null +++ b/public/app/features/preferences/api/user/endpoints.gen.ts @@ -0,0 +1,102 @@ +import { baseAPI as api } from './baseAPI'; +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getUserPreferences: build.query({ + query: () => ({ url: `/user/preferences` }), + }), + patchUserPreferences: build.mutation({ + query: (queryArg) => ({ url: `/user/preferences`, method: 'PATCH', body: queryArg.patchPrefsCmd }), + }), + updateUserPreferences: build.mutation({ + query: (queryArg) => ({ url: `/user/preferences`, method: 'PUT', body: queryArg.updatePrefsCmd }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as generatedAPI }; +export type GetUserPreferencesApiResponse = /** status 200 (empty) */ Preferences; +export type GetUserPreferencesApiArg = void; +export type PatchUserPreferencesApiResponse = + /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; +export type PatchUserPreferencesApiArg = { + patchPrefsCmd: PatchPrefsCmd; +}; +export type UpdateUserPreferencesApiResponse = + /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; +export type UpdateUserPreferencesApiArg = { + updatePrefsCmd: UpdatePrefsCmd; +}; +export type CookiePreferencesDefinesModelForCookiePreferences = { + analytics?: { + [key: string]: any; + }; + functional?: { + [key: string]: any; + }; + performance?: { + [key: string]: any; + }; +}; +export type NavbarPreferenceDefinesModelForNavbarPreference = { + savedItemIds?: string[]; +}; +export type QueryHistoryPreferenceDefinesModelForQueryHistoryPreference = { + /** HomeTab one of: '' | 'query' | 'starred'; */ + homeTab?: string; +}; +export type Preferences = { + cookiePreferences?: CookiePreferencesDefinesModelForCookiePreferences; + /** UID for the home dashboard */ + homeDashboardUID?: string; + /** Selected language (beta) */ + language?: string; + navbar?: NavbarPreferenceDefinesModelForNavbarPreference; + queryHistory?: QueryHistoryPreferenceDefinesModelForQueryHistoryPreference; + /** Theme light, dark, empty is default */ + theme?: string; + /** The timezone selection + TODO: this should use the timezone defined in common */ + timezone?: string; + /** WeekStart day of the week (sunday, monday, etc) */ + weekStart?: string; +}; +export type ErrorResponseBody = { + /** Error An optional detailed description of the actual error. Only included if running in developer mode. */ + error?: string; + /** a human readable version of the error */ + message: string; + /** Status An optional status to denote the cause of the error. + + For example, a 412 Precondition Failed error may include additional information of why that error happened. */ + status?: string; +}; +export type SuccessResponseBody = { + message?: string; +}; +export type CookieType = string; +export type PatchPrefsCmd = { + cookies?: CookieType[]; + /** The numerical :id of a favorited dashboard */ + homeDashboardId?: number; + homeDashboardUID?: string; + language?: string; + navbar?: NavbarPreferenceDefinesModelForNavbarPreference; + queryHistory?: QueryHistoryPreferenceDefinesModelForQueryHistoryPreference; + theme?: 'light' | 'dark'; + timezone?: 'utc' | 'browser'; + weekStart?: string; +}; +export type UpdatePrefsCmd = { + cookies?: CookieType[]; + /** The numerical :id of a favorited dashboard */ + homeDashboardId?: number; + homeDashboardUID?: string; + language?: string; + navbar?: NavbarPreferenceDefinesModelForNavbarPreference; + queryHistory?: QueryHistoryPreferenceDefinesModelForQueryHistoryPreference; + theme?: 'light' | 'dark' | 'system'; + timezone?: 'utc' | 'browser'; + weekStart?: string; +}; +export const { useGetUserPreferencesQuery, usePatchUserPreferencesMutation, useUpdateUserPreferencesMutation } = + injectedRtkApi; diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 6d43a8bb000..6697ec42208 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -5,6 +5,7 @@ import { Middleware } from 'redux'; 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'; +import { userPreferencesAPI } from 'app/features/preferences/api'; import { StoreState } from 'app/types/store'; import { buildInitialState } from '../core/reducers/navModel'; @@ -39,6 +40,7 @@ export function configureStore(initialState?: Partial) { browseDashboardsAPI.middleware, cloudMigrationAPI.middleware, queryLibraryApi.middleware, + userPreferencesAPI.middleware, ...extraMiddleware ), devTools: process.env.NODE_ENV !== 'production', diff --git a/scripts/generate-rtk-apis.ts b/scripts/generate-rtk-apis.ts index 72724c99a84..f965b2c044d 100644 --- a/scripts/generate-rtk-apis.ts +++ b/scripts/generate-rtk-apis.ts @@ -30,6 +30,11 @@ const config: ConfigFile = { 'getDashboardByUid', ], }, + '../public/app/features/preferences/api/user/endpoints.gen.ts': { + apiFile: '../public/app/features/preferences/api/user/baseAPI.ts', + apiImport: 'baseAPI', + filterEndpoints: ['getUserPreferences', 'updateUserPreferences', 'patchUserPreferences'], + }, }, };