Nav: Add items to saved (#89908)

* App events: Add info notification type

* Revert state

* Use info alert

* Nav: Enable saving items

* Use local state

* Use RTK query

* Revert go.work

* Revert

* User-specific queries

* Add memo

* Fix base URL

* Switch to ids

* Fix memo

* Add codeowners

* Generate API

* Separate user prefs API

* Remove tag

* Update export

* Use feature toggle
This commit is contained in:
Alex Khomenko 2024-07-05 16:01:10 +03:00 committed by GitHub
parent 7bf8375b02
commit 7111c52e4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 249 additions and 4 deletions

1
.github/CODEOWNERS vendored
View File

@ -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

View File

@ -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 (
<div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}>
<div className={styles.mobileHeader}>
@ -79,8 +107,10 @@ export const MegaMenu = memo(
)}
<MegaMenuItem
link={link}
isPinned={isPinned}
onClick={state.megaMenuDocked ? undefined : onClose}
activeItem={activeItem}
onPin={onPinItem}
/>
</Stack>
))}

View File

@ -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)}
>
<div
className={cx(styles.labelWrapper, {
@ -127,6 +132,8 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) {
activeItem={activeItem}
onClick={onClick}
level={level + 1}
onPin={onPin}
isPinned={isPinned}
/>
))
) : (

View File

@ -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' && <Icon data-testid="external-link-icon" name="external-link-alt" />
}
{config.featureToggles.pinNavItems && (
<Icon
name={isPinned ? 'favorite' : 'star'}
className={'pin-icon'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onPin(id);
}}
/>
)}
</div>
);
@ -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',
},
},
}),
});

View File

@ -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 [];
};

View File

@ -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 = {};

View File

@ -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;
};

View File

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

View File

@ -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<RequestOptions> {
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: () => ({}),
});

View File

@ -0,0 +1,102 @@
import { baseAPI as api } from './baseAPI';
const injectedRtkApi = api.injectEndpoints({
endpoints: (build) => ({
getUserPreferences: build.query<GetUserPreferencesApiResponse, GetUserPreferencesApiArg>({
query: () => ({ url: `/user/preferences` }),
}),
patchUserPreferences: build.mutation<PatchUserPreferencesApiResponse, PatchUserPreferencesApiArg>({
query: (queryArg) => ({ url: `/user/preferences`, method: 'PATCH', body: queryArg.patchPrefsCmd }),
}),
updateUserPreferences: build.mutation<UpdateUserPreferencesApiResponse, UpdateUserPreferencesApiArg>({
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;

View File

@ -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<StoreState>) {
browseDashboardsAPI.middleware,
cloudMigrationAPI.middleware,
queryLibraryApi.middleware,
userPreferencesAPI.middleware,
...extraMiddleware
),
devTools: process.env.NODE_ENV !== 'production',

View File

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