diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index 7b8cffdc428..2cc0cc42515 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -10,6 +10,7 @@ import { getFocusStyles } from '../../themes/mixins'; import { IconName } from '../../types'; import { clearButtonStyles } from '../Button'; import { Icon } from '../Icon/Icon'; +import { Tooltip } from '../Tooltip/Tooltip'; import { Counter } from './Counter'; @@ -25,10 +26,14 @@ export interface TabProps extends HTMLProps { /** Extra content, displayed after the tab label and counter */ suffix?: NavModelItem['tabSuffix']; truncate?: boolean; + tooltip?: string; } export const Tab = React.forwardRef( - ({ label, active, icon, onChangeTab, counter, suffix: Suffix, className, href, truncate, ...otherProps }, ref) => { + ( + { label, active, icon, onChangeTab, counter, suffix: Suffix, className, href, truncate, tooltip, ...otherProps }, + ref + ) => { const tabsStyles = useStyles2(getStyles); const clearStyles = useStyles2(clearButtonStyles); @@ -55,10 +60,13 @@ export const Tab = React.forwardRef( onClick: onChangeTab, role: 'tab', 'aria-selected': active, + title: !!tooltip ? undefined : otherProps.title, // If tooltip is provided, don't set the title on the link or button, it looks weird }; + let tab = null; + if (href) { - return ( + tab = (
(
); + } else { + tab = ( +
+ +
+ ); } - return ( -
- -
- ); + if (tooltip) { + return {tab}; + } + + return tab; } ); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index 0518d25023f..135ebb139c1 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -210,4 +210,10 @@ export class RowItem public setCollapsedState(collapse: boolean) { this.setState({ collapse }); } + + public hasUniqueTitle(): boolean { + const parentLayout = this.getParentLayout(); + const duplicateTitles = parentLayout.duplicateTitles(); + return !duplicateTitles.has(this.state.title); + } } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx index 2e3a91a2870..f195226097f 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import { Alert, Input, Switch, TextLink } from '@grafana/ui'; +import { Alert, Input, Switch, TextLink, Field } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -81,14 +81,22 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[] function RowTitleInput({ row }: { row: RowItem }) { const { title, isNew } = row.useState(); const ref = useEditPaneInputAutoFocus({ autoFocus: isNew }); + const hasUniqueTitle = row.hasUniqueTitle(); return ( - row.onChangeTitle(e.currentTarget.value)} - /> + + row.onChangeTitle(e.currentTarget.value)} + /> + ); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index ff61f974d0d..c0898fb0569 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -114,6 +114,13 @@ export function RowItemRenderer({ model }: SceneComponentProps) { )} role="heading" > + {!model.hasUniqueTitle() && ( + + + + )} {title} {isHeaderHidden && ( i return new RowsLayoutManager({ rows }); } + + public duplicateTitles(): Set { + const titleCounts = new Map(); + const duplicateTitles = new Set(); + + this.state.rows.forEach((row) => { + const title = row.state.title; + const count = (titleCounts.get(title) ?? 0) + 1; + titleCounts.set(title, count); + if (count > 1 && title) { + duplicateTitles.add(title); + } + }); + + return duplicateTitles; + } } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index afa22d1792a..0074347ebb2 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -180,4 +180,10 @@ export class TabItem scrollCanvasElementIntoView(this, this.containerRef); } + + public hasUniqueTitle(): boolean { + const parentLayout = this.getParentLayout(); + const duplicateTitles = parentLayout.duplicateTitles(); + return !duplicateTitles.has(this.state.title); + } } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 9b2ff27e38c..4754b3c2d15 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { Input } from '@grafana/ui'; +import { Input, Field } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -44,6 +44,21 @@ export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[] function TabTitleInput({ tab }: { tab: TabItem }) { const { title, isNew } = tab.useState(); const ref = useEditPaneInputAutoFocus({ autoFocus: isNew }); + const hasUniqueTitle = tab.hasUniqueTitle(); - return tab.onChangeTitle(e.currentTarget.value)} />; + return ( + + tab.onChangeTitle(e.currentTarget.value)} + /> + + ); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index 7f44fb2f0c3..2400ce19cfb 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -5,6 +5,7 @@ import { useLocation } from 'react-router'; import { locationUtil, textUtil } from '@grafana/data'; import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; import { Tab, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; @@ -32,6 +33,15 @@ export function TabItemRenderer({ model }: SceneComponentProps) { return null; } + let titleCollisionProps = {}; + + if (!model.hasUniqueTitle()) { + titleCollisionProps = { + icon: 'exclamation-triangle', + tooltip: t('dashboard.tabs-layout.tab-warning.title-not-unique', 'This title is not unique'), + }; + } + return ( {(dragProvided, dragSnapshot) => ( @@ -70,6 +80,7 @@ export function TabItemRenderer({ model }: SceneComponentProps) { }} label={titleInterpolated} data-dashboard-drop-target-key={model.state.key} + {...titleCollisionProps} /> )} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 1033b86ef6e..3de76a2e053 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -233,4 +233,20 @@ export class TabsLayoutManager extends SceneObjectBase i return key; } + + public duplicateTitles() { + const titleCounts = new Map(); + const duplicateTitles = new Set(); + + this.state.tabs.forEach((tab) => { + const title = tab.state.title; + const count = (titleCounts.get(title) ?? 0) + 1; + titleCounts.set(title, count); + if (count > 1) { + duplicateTitles.add(title); + } + }); + + return duplicateTitles; + } } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e5cb280e22d..9fb06287864 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1821,7 +1821,11 @@ "hide-header": "Hide row header", "title": "Title" }, + "title-not-unique": "Title should be unique", "title-option": "Title" + }, + "row-warning": { + "title-not-unique": "This title is not unique" } }, "save-dashboard-as-button": { @@ -1872,7 +1876,11 @@ "new": "New tab" }, "tab-options": { + "title-not-unique": "Title should be unique", "title-option": "Title" + }, + "tab-warning": { + "title-not-unique": "This title is not unique" } }, "toolbar": {