SingleTopNav: Add `toolbar` to `Page` and replace usage of `AppChromeUpdate` (#94022)

* add page-level toolbar for actions

* handle explore

* fix panel edit sizing

* remove comments

* remove TOGGLE_BUTTON_ID

* undo alerting changes

* use fixed position header

* feature toggle Page changes

* add page context for alerting use cases

* simplify

* prettier...
pull/94188/head^2
Ashley Harrison 9 months ago committed by GitHub
parent e48d166c3e
commit dd7f45011d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      public/app/core/components/AppChrome/AppChromeUpdate.tsx
  2. 124
      public/app/core/components/Page/Page.tsx
  3. 47
      public/app/core/components/Page/PageToolbarActions.tsx
  4. 2
      public/app/core/components/Page/types.ts
  5. 5
      public/app/features/alerting/unified/CloneRuleEditor.test.tsx
  6. 44
      public/app/features/alerting/unified/components/receivers/TemplateForm.tsx
  7. 212
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
  8. 46
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx
  9. 21
      public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
  10. 24
      public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx
  11. 23
      public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx
  12. 12
      public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx
  13. 13
      public/app/features/dashboard-scene/settings/JsonModelEditView.tsx
  14. 13
      public/app/features/dashboard-scene/settings/PermissionsEditView.tsx
  15. 23
      public/app/features/dashboard-scene/settings/VariablesEditView.tsx
  16. 13
      public/app/features/dashboard-scene/settings/VersionsEditView.tsx
  17. 12
      public/app/features/explore/ExploreToolbar.tsx

@ -7,8 +7,7 @@ export interface AppChromeUpdateProps {
actions?: React.ReactNode;
}
/**
* This needs to be moved to @grafana/ui or runtime.
* This is the way core pages and plugins update the breadcrumbs and page toolbar actions
* @deprecated This component is deprecated and will be removed in a future release.
*/
export const AppChromeUpdate = React.memo<AppChromeUpdateProps>(({ actions }: AppChromeUpdateProps) => {
const { chrome } = useGrafana();

@ -1,19 +1,58 @@
import { css, cx } from '@emotion/css';
import { useLayoutEffect } from 'react';
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { TOP_BAR_LEVEL_HEIGHT } from '../AppChrome/types';
import NativeScrollbar from '../NativeScrollbar';
import { PageContents } from './PageContents';
import { PageHeader } from './PageHeader';
import { PageTabs } from './PageTabs';
import { PageToolbarActions } from './PageToolbarActions';
import { PageType } from './types';
import { usePageNav } from './usePageNav';
import { usePageTitle } from './usePageTitle';
export interface PageContextType {
setToolbar: Dispatch<SetStateAction<ReactNode>>;
}
export const PageContext = createContext<PageContextType | undefined>(undefined);
function usePageContext(): PageContextType {
const context = useContext(PageContext);
if (!context) {
throw new Error('No PageContext found');
}
return context;
}
/**
* Hook to dynamically set the toolbar of a Page from a child component.
* Prefer setting the toolbar directly as a prop to Page.
* @param toolbar a ReactNode that will be rendered in a second toolbar
*/
export function usePageToolbar(toolbar?: ReactNode) {
const { setToolbar } = usePageContext();
useEffect(() => {
setToolbar(toolbar);
return () => setToolbar(undefined);
}, [setToolbar, toolbar]);
}
export const Page: PageType = ({
navId,
navModel: oldNavProp,
@ -24,12 +63,15 @@ export const Page: PageType = ({
subTitle,
children,
className,
toolbar: toolbarProp,
info,
layout = PageLayoutType.Standard,
onSetScrollRef,
...otherProps
}) => {
const styles = useStyles2(getStyles);
const isSingleTopNav = config.featureToggles.singleTopNav;
const [toolbar, setToolbar] = useState(toolbarProp);
const styles = useStyles2(getStyles, Boolean(isSingleTopNav && toolbar));
const navModel = usePageNav(navId, oldNavProp);
const { chrome } = useGrafana();
@ -50,54 +92,58 @@ export const Page: PageType = ({
}, [navModel, pageNav, chrome, layout]);
return (
<div className={cx(styles.wrapper, className)} {...otherProps}>
{layout === PageLayoutType.Standard && (
<NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar"
onSetScrollRef={onSetScrollRef}
>
<div className={styles.pageInner}>
{pageHeaderNav && (
<PageHeader
actions={actions}
onEditTitle={onEditTitle}
navItem={pageHeaderNav}
renderTitle={renderTitle}
info={info}
subTitle={subTitle}
/>
)}
{pageNav && pageNav.children && <PageTabs navItem={pageNav} />}
<div className={styles.pageContent}>{children}</div>
</div>
</NativeScrollbar>
)}
{layout === PageLayoutType.Canvas && (
<NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar"
onSetScrollRef={onSetScrollRef}
>
<div className={styles.canvasContent}>{children}</div>
</NativeScrollbar>
)}
{layout === PageLayoutType.Custom && children}
</div>
<PageContext.Provider value={{ setToolbar }}>
<div className={cx(styles.wrapper, className)} {...otherProps}>
{isSingleTopNav && toolbar && <PageToolbarActions>{toolbar}</PageToolbarActions>}
{layout === PageLayoutType.Standard && (
<NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar"
onSetScrollRef={onSetScrollRef}
>
<div className={styles.pageInner}>
{pageHeaderNav && (
<PageHeader
actions={actions}
onEditTitle={onEditTitle}
navItem={pageHeaderNav}
renderTitle={renderTitle}
info={info}
subTitle={subTitle}
/>
)}
{pageNav && pageNav.children && <PageTabs navItem={pageNav} />}
<div className={styles.pageContent}>{children}</div>
</div>
</NativeScrollbar>
)}
{layout === PageLayoutType.Canvas && (
<NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar"
onSetScrollRef={onSetScrollRef}
>
<div className={styles.canvasContent}>{children}</div>
</NativeScrollbar>
)}
{layout === PageLayoutType.Custom && children}
</div>
</PageContext.Provider>
);
};
Page.Contents = PageContents;
const getStyles = (theme: GrafanaTheme2) => {
const getStyles = (theme: GrafanaTheme2, hasToolbar: boolean) => {
return {
wrapper: css({
label: 'page-wrapper',
display: 'flex',
flex: '1 1 0',
flexDirection: 'column',
marginTop: hasToolbar ? TOP_BAR_LEVEL_HEIGHT : 0,
position: 'relative',
}),
pageContent: css({

@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import { PropsWithChildren } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { useChromeHeaderHeight } from '@grafana/runtime';
import { Stack, useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { MENU_WIDTH } from '../AppChrome/MegaMenu/MegaMenu';
import { TOP_BAR_LEVEL_HEIGHT } from '../AppChrome/types';
export interface Props {}
export function PageToolbarActions({ children }: PropsWithChildren<Props>) {
const chromeHeaderHeight = useChromeHeaderHeight();
const { chrome } = useGrafana();
const state = chrome.useState();
const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen;
const styles = useStyles2(getStyles, chromeHeaderHeight ?? 0, menuDockedAndOpen);
return (
<div data-testid={Components.NavToolbar.container} className={styles.pageToolbar}>
<Stack alignItems="center" justifyContent="flex-end" flex={1} wrap="nowrap" minWidth={0}>
{children}
</Stack>
</div>
);
}
const getStyles = (theme: GrafanaTheme2, chromeHeaderHeight: number, menuDockedAndOpen: boolean) => {
return {
pageToolbar: css({
alignItems: 'center',
backgroundColor: theme.colors.background.primary,
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
height: TOP_BAR_LEVEL_HEIGHT,
left: menuDockedAndOpen ? MENU_WIDTH : 0,
padding: theme.spacing(0, 1, 0, 2),
position: 'fixed',
top: chromeHeaderHeight,
right: 0,
zIndex: theme.zIndex.navbarFixed,
}),
};
};

@ -25,6 +25,8 @@ export interface PageProps extends HTMLAttributes<HTMLDivElement> {
layout?: PageLayoutType;
/** Can be used to get the scroll container element to access scroll position */
onSetScrollRef?: (ref: ScrollRefElement) => void;
/** Set a page-level toolbar */
toolbar?: React.ReactNode;
}
export interface PageInfoItem {

@ -5,6 +5,7 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors/src';
import { setDataSourceSrv } from '@grafana/runtime';
import { PageContext } from 'app/core/components/Page/Page';
import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
@ -72,7 +73,9 @@ function Wrapper({ children }: React.PropsWithChildren<{}>) {
const formApi = useForm<RuleFormValues>({ defaultValues: getDefaultFormValues() });
return (
<Providers>
<FormProvider {...formApi}>{children}</FormProvider>
<PageContext.Provider value={{ setToolbar: jest.fn() }}>
<FormProvider {...formApi}>{children}</FormProvider>
</PageContext.Provider>
</Providers>
);
}

@ -1,13 +1,13 @@
import { css, cx } from '@emotion/css';
import { addMinutes, subDays, subHours } from 'date-fns';
import { Location } from 'history';
import { useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useToggle } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { isFetchError, locationService } from '@grafana/runtime';
import { config as runtimeConfig, isFetchError, locationService } from '@grafana/runtime';
import {
Alert,
Button,
@ -21,6 +21,7 @@ import {
InlineField,
Box,
} from '@grafana/ui';
import { usePageToolbar } from 'app/core/components/Page/Page';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { ActiveTab as ContactPointsActiveTabs } from 'app/features/alerting/unified/components/contact-points/ContactPoints';
@ -157,28 +158,33 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props)
}
};
const actionButtons = (
<Stack>
<Button onClick={() => formRef.current?.requestSubmit()} variant="primary" size="sm" disabled={isSubmitting}>
Save
</Button>
<LinkButton
disabled={isSubmitting}
href={makeAMLink('alerting/notifications', alertmanager, {
tab: ContactPointsActiveTabs.NotificationTemplates,
})}
variant="secondary"
size="sm"
>
Cancel
</LinkButton>
</Stack>
const actionButtons = useMemo(
() => (
<Stack>
<Button onClick={() => formRef.current?.requestSubmit()} variant="primary" size="sm" disabled={isSubmitting}>
Save
</Button>
<LinkButton
disabled={isSubmitting}
href={makeAMLink('alerting/notifications', alertmanager, {
tab: ContactPointsActiveTabs.NotificationTemplates,
})}
variant="secondary"
size="sm"
>
Cancel
</LinkButton>
</Stack>
),
[alertmanager, isSubmitting]
);
usePageToolbar(actionButtons);
return (
<>
<FormProvider {...formApi}>
<AppChromeUpdate actions={actionButtons} />
{!runtimeConfig.featureToggles.singleTopNav && <AppChromeUpdate actions={actionButtons} />}
<form onSubmit={handleSubmit(submit)} ref={formRef} className={styles.form} aria-label="Template form">
{/* error message */}
{error && (

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
@ -7,6 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { Button, ConfirmModal, CustomScrollbar, Spinner, Stack, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { usePageToolbar } from 'app/core/components/Page/Page';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
@ -133,52 +134,66 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
};
// @todo why is error not propagated to form?
const submit = async (values: RuleFormValues, exitOnSave: boolean) => {
if (conditionErrorMsg !== '') {
notifyApp.error(conditionErrorMsg);
return;
}
trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type });
const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values);
const ruleGroupIdentifier = existing
? getRuleGroupLocationFromRuleWithLocation(existing)
: getRuleGroupLocationFromFormValues(values);
// @TODO move this to a hook too to make sure the logic here is tested for regressions?
if (!existing) {
// when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage
storeInLocalStorageValues(values);
await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, evaluateEvery);
} else {
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
const targetRuleGroupIdentifier = getRuleGroupLocationFromFormValues(values);
await updateRuleInRuleGroup.execute(
ruleGroupIdentifier,
ruleIdentifier,
ruleDefinition,
targetRuleGroupIdentifier,
evaluateEvery
);
}
const { dataSourceName, namespaceName, groupName } = ruleGroupIdentifier;
if (exitOnSave) {
const returnTo = queryParams.get('returnTo') || getReturnToUrl(ruleGroupIdentifier, ruleDefinition);
locationService.push(returnTo);
return;
}
// Cloud Ruler rules identifier changes on update due to containing rule name and hash components
// After successful update we need to update the URL to avoid displaying 404 errors
if (isCloudRulerRule(ruleDefinition)) {
const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, ruleDefinition);
locationService.replace(`/alerting/${encodeURIComponent(stringifyIdentifier(updatedRuleIdentifier))}/edit`);
}
};
const submit = useCallback(
async (values: RuleFormValues, exitOnSave: boolean) => {
if (conditionErrorMsg !== '') {
notifyApp.error(conditionErrorMsg);
return;
}
trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type });
const ruleDefinition = grafanaTypeRule
? formValuesToRulerGrafanaRuleDTO(values)
: formValuesToRulerRuleDTO(values);
const ruleGroupIdentifier = existing
? getRuleGroupLocationFromRuleWithLocation(existing)
: getRuleGroupLocationFromFormValues(values);
// @TODO move this to a hook too to make sure the logic here is tested for regressions?
if (!existing) {
// when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage
storeInLocalStorageValues(values);
await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, evaluateEvery);
} else {
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
const targetRuleGroupIdentifier = getRuleGroupLocationFromFormValues(values);
await updateRuleInRuleGroup.execute(
ruleGroupIdentifier,
ruleIdentifier,
ruleDefinition,
targetRuleGroupIdentifier,
evaluateEvery
);
}
const { dataSourceName, namespaceName, groupName } = ruleGroupIdentifier;
if (exitOnSave) {
const returnTo = queryParams.get('returnTo') || getReturnToUrl(ruleGroupIdentifier, ruleDefinition);
locationService.push(returnTo);
return;
}
// Cloud Ruler rules identifier changes on update due to containing rule name and hash components
// After successful update we need to update the URL to avoid displaying 404 errors
if (isCloudRulerRule(ruleDefinition)) {
const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, ruleDefinition);
locationService.replace(`/alerting/${encodeURIComponent(stringifyIdentifier(updatedRuleIdentifier))}/edit`);
}
},
[
addRuleToRuleGroup,
conditionErrorMsg,
evaluateEvery,
existing,
grafanaTypeRule,
notifyApp,
queryParams,
updateRuleInRuleGroup,
]
);
const deleteRule = async () => {
if (existing) {
@ -191,71 +206,78 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
}
};
const onInvalid: SubmitErrorHandler<RuleFormValues> = (errors): void => {
trackAlertRuleFormError({
grafana_version: config.buildInfo.version,
org_id: contextSrv.user.orgId,
user_id: contextSrv.user.id,
error: Object.keys(errors).toString(),
formAction: existing ? 'update' : 'create',
});
notifyApp.error('There are errors in the form. Please correct them and try again!');
};
const onInvalid: SubmitErrorHandler<RuleFormValues> = useCallback(
(errors): void => {
trackAlertRuleFormError({
grafana_version: config.buildInfo.version,
org_id: contextSrv.user.orgId,
user_id: contextSrv.user.id,
error: Object.keys(errors).toString(),
formAction: existing ? 'update' : 'create',
});
notifyApp.error('There are errors in the form. Please correct them and try again!');
},
[existing, notifyApp]
);
const cancelRuleCreation = () => {
const cancelRuleCreation = useCallback(() => {
logInfo(LogMessages.cancelSavingAlertRule);
trackAlertRuleFormCancelled({ formAction: existing ? 'update' : 'create' });
locationService.getHistory().goBack();
};
}, [existing]);
const evaluateEveryInForm = watch('evaluateEvery');
useEffect(() => setEvaluateEvery(evaluateEveryInForm), [evaluateEveryInForm]);
const actionButtons = (
<Stack justifyContent="flex-end" alignItems="center">
{existing && (
const actionButtons = useMemo(
() => (
<Stack justifyContent="flex-end" alignItems="center">
{existing && (
<Button
variant="primary"
type="button"
size="sm"
onClick={handleSubmit((values) => submit(values, false), onInvalid)}
disabled={isSubmitting}
>
{isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
Save rule
</Button>
)}
<Button
variant="primary"
type="button"
size="sm"
onClick={handleSubmit((values) => submit(values, false), onInvalid)}
onClick={handleSubmit((values) => submit(values, true), onInvalid)}
disabled={isSubmitting}
>
{isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
Save rule
</Button>
)}
<Button
variant="primary"
type="button"
size="sm"
onClick={handleSubmit((values) => submit(values, true), onInvalid)}
disabled={isSubmitting}
>
{isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
Save rule and exit
</Button>
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation} size="sm">
Cancel
</Button>
{existing ? (
<Button fill="outline" variant="destructive" type="button" onClick={() => setShowDeleteModal(true)} size="sm">
Delete
Save rule and exit
</Button>
) : null}
{existing && isCortexLokiOrRecordingRule(watch) && (
<Button
variant="secondary"
type="button"
onClick={() => setShowEditYaml(true)}
disabled={isSubmitting}
size="sm"
>
Edit YAML
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation} size="sm">
Cancel
</Button>
)}
</Stack>
{existing ? (
<Button fill="outline" variant="destructive" type="button" onClick={() => setShowDeleteModal(true)} size="sm">
Delete
</Button>
) : null}
{existing && isCortexLokiOrRecordingRule(watch) && (
<Button
variant="secondary"
type="button"
onClick={() => setShowEditYaml(true)}
disabled={isSubmitting}
size="sm"
>
Edit YAML
</Button>
)}
</Stack>
),
[cancelRuleCreation, existing, handleSubmit, isSubmitting, onInvalid, styles.buttonSpinner, submit, watch]
);
usePageToolbar(actionButtons);
const isPaused = existing && isGrafanaRulerRule(existing.rule) && isGrafanaRulerRulePaused(existing.rule);
if (!type) {
@ -263,7 +285,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
}
return (
<FormProvider {...formAPI}>
<AppChromeUpdate actions={actionButtons} />
{!config.featureToggles.singleTopNav && <AppChromeUpdate actions={actionButtons} />}
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
<div className={styles.contentOuter}>
{isPaused && <InfoPausedRule />}

@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useAsync } from 'react-use';
import { config } from '@grafana/runtime';
import { Button, CustomScrollbar, LinkButton, LoadingPlaceholder, Stack } from '@grafana/ui';
import { usePageToolbar } from 'app/core/components/Page/Page';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
@ -50,39 +52,47 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
const [conditionErrorMsg, setConditionErrorMsg] = useState('');
const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
const onInvalid = (): void => {
const onInvalid = useCallback((): void => {
notifyApp.error('There are errors in the form. Please correct them and try again!');
};
}, [notifyApp]);
const checkAlertCondition = (msg = '') => {
setConditionErrorMsg(msg);
};
const submit = (exportData: RuleFormValues | undefined) => {
if (conditionErrorMsg !== '') {
notifyApp.error(conditionErrorMsg);
return;
}
setExportData(exportData);
};
const submit = useCallback(
(exportData: RuleFormValues | undefined) => {
if (conditionErrorMsg !== '') {
notifyApp.error(conditionErrorMsg);
return;
}
setExportData(exportData);
},
[conditionErrorMsg, notifyApp]
);
const onClose = useCallback(() => {
setExportData(undefined);
}, [setExportData]);
const actionButtons = [
<LinkButton href={returnTo} key="cancel" size="sm" variant="secondary" onClick={() => submit(undefined)}>
Cancel
</LinkButton>,
<Button key="export-rule" size="sm" onClick={formAPI.handleSubmit((formValues) => submit(formValues), onInvalid)}>
Export
</Button>,
];
const actionButtons = useMemo(
() => [
<LinkButton href={returnTo} key="cancel" size="sm" variant="secondary" onClick={() => submit(undefined)}>
Cancel
</LinkButton>,
<Button key="export-rule" size="sm" onClick={formAPI.handleSubmit((formValues) => submit(formValues), onInvalid)}>
Export
</Button>,
],
[formAPI, onInvalid, returnTo, submit]
);
usePageToolbar(actionButtons);
return (
<>
<FormProvider {...formAPI}>
<AppChromeUpdate actions={actionButtons} />
{!config.featureToggles.singleTopNav && <AppChromeUpdate actions={actionButtons} />}
<form onSubmit={(e) => e.preventDefault()}>
<div>
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>

@ -3,9 +3,10 @@ import { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { useChromeHeaderHeight } from '@grafana/runtime';
import { config, useChromeHeaderHeight } from '@grafana/runtime';
import { SceneComponentProps } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { TOP_BAR_LEVEL_HEIGHT } from 'app/core/components/AppChrome/types';
import NativeScrollbar from 'app/core/components/NativeScrollbar';
import { Page } from 'app/core/components/Page/Page';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
@ -14,7 +15,7 @@ import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty';
import { useSelector } from 'app/types';
import { DashboardScene } from './DashboardScene';
import { NavToolbarActions } from './NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from './NavToolbarActions';
import { PanelSearchLayout } from './PanelSearchLayout';
import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner';
@ -22,7 +23,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const { controls, overlay, editview, editPanel, isEmpty, meta, viewPanelScene, panelSearch, panelsPerRow } =
model.useState();
const headerHeight = useChromeHeaderHeight();
const styles = useStyles2(getStyles, headerHeight);
const styles = useStyles2(getStyles, headerHeight ?? 0);
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const pageNav = model.getPageNav(location, navIndex);
@ -30,6 +31,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const navModel = getNavModel(navIndex, 'dashboards/browse');
const hasControls = controls?.hasControls();
const isSettingsOpen = editview !== undefined;
const isSingleTopNav = config.featureToggles.singleTopNav;
// Remember scroll pos when going into view panel, edit panel or settings
useMemo(() => {
@ -79,12 +81,17 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
}
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Custom}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={model} /> : undefined}
>
{editPanel && <editPanel.Component model={editPanel} />}
{!editPanel && (
<NativeScrollbar divId="page-scrollbar" onSetScrollRef={model.onSetScrollRef}>
<div className={cx(styles.pageContainer, hasControls && styles.pageContainerWithControls)}>
<NavToolbarActions dashboard={model} />
{!isSingleTopNav && <NavToolbarActions dashboard={model} />}
{controls && (
<div className={styles.controlsWrapper}>
<controls.Component model={controls} />
@ -99,7 +106,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
);
}
function getStyles(theme: GrafanaTheme2, headerHeight: number | undefined) {
function getStyles(theme: GrafanaTheme2, headerHeight: number) {
return {
pageContainer: css({
display: 'grid',
@ -133,7 +140,7 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number | undefined) {
position: 'sticky',
zIndex: theme.zIndex.activePanel,
background: theme.colors.background.canvas,
top: headerHeight,
top: config.featureToggles.singleTopNav ? headerHeight + TOP_BAR_LEVEL_HEIGHT : headerHeight,
},
}),
canvasContent: css({

@ -1,11 +1,11 @@
import { AnnotationQuery, getDataSourceRef, NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, VizPanel, dataLayers } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from '../scene/NavToolbarActions';
import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils';
@ -133,6 +133,7 @@ function AnnotationsSettingsView({ model }: SceneComponentProps<AnnotationsEditV
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const { editIndex } = model.useState();
const panels = dashboardSceneGraph.getVizPanels(dashboard);
const isSingleTopNav = config.featureToggles.singleTopNav;
const annotations: AnnotationQuery[] = dataLayersToAnnotations(annotationLayers);
@ -153,8 +154,13 @@ function AnnotationsSettingsView({ model }: SceneComponentProps<AnnotationsEditV
}
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<AnnotationSettingsList
annotations={annotations}
onNew={model.onNew}
@ -190,6 +196,7 @@ function AnnotationsSettingsEditView({
onDelete,
}: AnnotationsSettingsEditViewProps) {
const { name, query } = annotationLayer.useState();
const isSingleTopNav = config.featureToggles.singleTopNav;
const editAnnotationPageNav = {
text: name,
@ -197,8 +204,13 @@ function AnnotationsSettingsEditView({
};
return (
<Page navModel={navModel} pageNav={editAnnotationPageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={editAnnotationPageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<AnnotationSettingsEdit
annotation={query}
editIndex={editIndex}

@ -1,10 +1,11 @@
import { NavModel, NavModelItem, PageLayoutType, arrayUtils } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
import { DashboardLink } from '@grafana/schema';
import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from '../scene/NavToolbarActions';
import { DashboardLinkForm } from '../settings/links/DashboardLinkForm';
import { DashboardLinkList } from '../settings/links/DashboardLinkList';
import { NEW_LINK } from '../settings/links/utils';
@ -80,6 +81,7 @@ function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<Dashboard
const { links } = dashboard.useState();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const linkToEdit = editIndex !== undefined ? links[editIndex] : undefined;
const isSingleTopNav = config.featureToggles.singleTopNav;
if (linkToEdit) {
return (
@ -95,8 +97,13 @@ function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<Dashboard
}
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<DashboardLinkList
links={links}
onNew={model.onNewLink}
@ -123,10 +130,16 @@ function EditLinkView({ pageNav, link, navModel, dashboard, onChange, onGoBack }
text: 'Edit link',
parentItem: pageNav,
};
const isSingleTopNav = config.featureToggles.singleTopNav;
return (
<Page navModel={navModel} pageNav={editLinkPageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={editLinkPageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<DashboardLinkForm link={link!} onUpdate={onChange} onGoBack={onGoBack} />
</Page>
);

@ -25,7 +25,7 @@ import { GenAIDashTitleButton } from 'app/features/dashboard/components/GenAI/Ge
import { updateNavModel } from '../pages/utils';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from '../scene/NavToolbarActions';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils';
@ -177,10 +177,16 @@ export class GeneralSettingsEditView
const { intervals } = model.getRefreshPicker().useState();
const { hideTimeControls } = model.getDashboardControls().useState();
const { enabled: liveNow } = model.getLiveNowTimer().useState();
const isSingleTopNav = config.featureToggles.singleTopNav;
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<div style={{ maxWidth: '600px' }}>
<Box marginBottom={5}>
<Field

@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { useState } from 'react';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, sceneUtils } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema';
import { Alert, Box, Button, CodeEditor, Stack, useStyles2 } from '@grafana/ui';
@ -18,7 +19,7 @@ import {
} from '../saving/shared';
import { useSaveDashboard } from '../saving/useSaveDashboard';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from '../scene/NavToolbarActions';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { getDashboardSceneFor } from '../utils/utils';
@ -88,6 +89,7 @@ export class JsonModelEditView extends SceneObjectBase<JsonModelEditViewState> i
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const canSave = dashboard.useState().meta.canSave;
const { jsonText } = model.useState();
const isSingleTopNav = config.featureToggles.singleTopNav;
const onSave = async (overwrite: boolean) => {
const result = await onSaveDashboard(dashboard, JSON.parse(model.state.jsonText), {
@ -174,8 +176,13 @@ export class JsonModelEditView extends SceneObjectBase<JsonModelEditViewState> i
);
}
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<div className={styles.wrapper}>
<Trans i18nKey="dashboard-settings.json-editor.subtitle">
The JSON model below is the data structure that defines the dashboard. This includes dashboard settings,

@ -1,4 +1,5 @@
import { PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
import { Permissions } from 'app/core/components/AccessControl';
import { Page } from 'app/core/components/Page/Page';
@ -6,7 +7,7 @@ import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
@ -34,10 +35,16 @@ function PermissionsEditorSettings({ model }: SceneComponentProps<PermissionsEdi
const { uid } = dashboard.useState();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite);
const isSingleTopNav = config.featureToggles.singleTopNav;
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<Permissions resource={'dashboards'} resourceId={uid ?? ''} canSetPermissions={canSetPermissions} />
</Page>
);

@ -1,9 +1,10 @@
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync';
@ -206,6 +207,7 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables
const { onDelete, onDuplicated, onOrderChanged, onEdit, onTypeChange, onGoBack, onAdd } = model;
const { variables } = model.getVariableSet().useState();
const { editIndex } = model.useState();
const isSingleTopNav = config.featureToggles.singleTopNav;
if (editIndex !== undefined && variables[editIndex]) {
const variable = variables[editIndex];
@ -226,8 +228,13 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables
}
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<VariableEditorList
variables={variables}
onDelete={onDelete}
@ -262,14 +269,20 @@ function VariableEditorSettingsView({
onValidateVariableName,
}: VariableEditorSettingsEditViewProps) {
const { name } = variable.useState();
const isSingleTopNav = config.featureToggles.singleTopNav;
const editVariablePageNav = {
text: name,
parentItem: pageNav,
};
return (
<Page navModel={navModel} pageNav={editVariablePageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={editVariablePageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
<VariableEditorForm
variable={variable}
onTypeChange={onTypeChange}

@ -1,12 +1,13 @@
import * as React from 'react';
import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
import { Spinner, Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { NavToolbarActions, ToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
@ -188,6 +189,7 @@ function VersionsEditorSettingsListView({ model }: SceneComponentProps<VersionsE
const showButtons = model.versions.length > 1;
const hasMore = model.versions.length >= model.limit;
const isLastPage = model.versions.find((rev) => rev.version === 1);
const isSingleTopNav = config.featureToggles.singleTopNav;
const viewModeCompare = (
<>
@ -237,8 +239,13 @@ function VersionsEditorSettingsListView({ model }: SceneComponentProps<VersionsE
);
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<Page
navModel={navModel}
pageNav={pageNav}
layout={PageLayoutType.Standard}
toolbar={isSingleTopNav ? <ToolbarActions dashboard={dashboard} /> : undefined}
>
{!isSingleTopNav && <NavToolbarActions dashboard={dashboard} />}
{viewMode === 'compare' ? viewModeCompare : viewModeList}
</Page>
);

@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { shallowEqual } from 'react-redux';
import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import {
defaultIntervals,
PageToolbar,
@ -89,6 +89,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
const correlationDetails = useSelector(selectCorrelationDetails);
const isCorrelationsEditorMode = correlationDetails?.editorMode || false;
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
const isSingleTopNav = config.featureToggles.singleTopNav;
const shouldRotateSplitIcon = useMemo(
() => (isLeftPane && isLargerPane) || (!isLeftPane && !isLargerPane),
@ -206,9 +207,11 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
return (
<div>
{refreshInterval && <SetInterval func={onRunQuery} interval={refreshInterval} loading={loading} />}
<div>
<AppChromeUpdate actions={navBarActions} />
</div>
{!isSingleTopNav && (
<div>
<AppChromeUpdate actions={navBarActions} />
</div>
)}
<PageToolbar
aria-label={t('explore.toolbar.aria-label', 'Explore toolbar')}
leftItems={[
@ -233,6 +236,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
hideTextValue={showSmallDataSourcePicker}
width={showSmallDataSourcePicker ? 8 : undefined}
/>,
isSingleTopNav && <ShortLinkButtonMenu key="share" />,
].filter(Boolean)}
forceShowLeftItems
>

Loading…
Cancel
Save