mirror of
https://github.com/grafana/grafana.git
synced 2024-12-25 08:21:46 -06:00
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:
parent
7bf8375b02
commit
7111c52e4c
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -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
|
||||
|
@ -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>
|
||||
))}
|
||||
|
@ -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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
14
public/app/core/components/AppChrome/MegaMenu/hooks.ts
Normal file
14
public/app/core/components/AppChrome/MegaMenu/hooks.ts
Normal 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 [];
|
||||
};
|
@ -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 = {};
|
||||
|
@ -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;
|
||||
};
|
||||
|
19
public/app/features/preferences/api/index.ts
Normal file
19
public/app/features/preferences/api/index.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
36
public/app/features/preferences/api/user/baseAPI.ts
Normal file
36
public/app/features/preferences/api/user/baseAPI.ts
Normal 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: () => ({}),
|
||||
});
|
102
public/app/features/preferences/api/user/endpoints.gen.ts
Normal file
102
public/app/features/preferences/api/user/endpoints.gen.ts
Normal 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;
|
@ -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',
|
||||
|
@ -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'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user