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'],
+ },
},
};