;
+ }
+
+ ComponentWithMeta.displayName = '';
+ ComponentWithMeta.propTypes = {};
+ ComponentWithMeta.contextTypes = {};
+ ComponentWithMeta.meta = {
+ id: id ?? '',
+ pluginId: pluginId ?? '',
+ title: '',
+ description: '',
+ type: PluginExtensionTypes.component,
+ } satisfies PluginExtensionComponentMeta;
+
+ return ComponentWithMeta;
+}
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.ts
deleted file mode 100644
index afd08da9e49..00000000000
--- a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {
- type PluginExtension,
- type PluginExtensionComponent,
- type PluginExtensionLink,
- PluginExtensionTypes,
-} from '@grafana/data';
-
-export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
- if (!extension) {
- return false;
- }
- return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
-}
-
-export function isPluginExtensionComponent(
- extension: PluginExtension | undefined
-): extension is PluginExtensionComponent {
- if (!extension) {
- return false;
- }
- return extension.type === PluginExtensionTypes.component && 'component' in extension;
-}
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx b/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx
new file mode 100644
index 00000000000..3bb27160ef5
--- /dev/null
+++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+
+import {
+ ComponentTypeWithExtensionMeta,
+ type PluginExtension,
+ type PluginExtensionComponent,
+ type PluginExtensionLink,
+ PluginExtensionTypes,
+} from '@grafana/data';
+
+export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
+ if (!extension) {
+ return false;
+ }
+ return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
+}
+
+export function isPluginExtensionComponent(
+ extension: PluginExtension | undefined
+): extension is PluginExtensionComponent {
+ if (!extension) {
+ return false;
+ }
+ return extension.type === PluginExtensionTypes.component && 'component' in extension;
+}
+
+export function getLimitedComponentsToRender({
+ props,
+ components,
+ limit,
+ pluginId,
+}: {
+ props: Props;
+ components: Array>;
+ limit?: number;
+ pluginId?: string | string[] | RegExp;
+}) {
+ if (!components.length) {
+ return null;
+ }
+
+ const renderedComponents: Array> = [];
+
+ for (const Component of components) {
+ const { meta } = Component;
+
+ if (pluginId && typeof pluginId === 'string' && pluginId !== meta.pluginId) {
+ continue;
+ }
+
+ if (pluginId && Array.isArray(pluginId) && !pluginId.includes(meta.pluginId)) {
+ continue;
+ }
+
+ if (pluginId instanceof RegExp && !pluginId.test(meta.pluginId)) {
+ continue;
+ }
+
+ // If no limit is provided, return all components
+ if (limit === undefined) {
+ renderedComponents.push(Component);
+ continue;
+ }
+
+ // If a component does not render anything, do not count it in the limit
+ if (React.createElement(Component, props) !== null) {
+ renderedComponents.push(Component);
+ }
+
+ // Stop if we've reached the limit
+ if (renderedComponents.length >= limit) {
+ break;
+ }
+ }
+
+ return renderedComponents;
+}
+
+export function renderLimitedComponents({
+ props,
+ components,
+ limit,
+ pluginId,
+}: {
+ props: Props;
+ components: Array>;
+ limit?: number;
+ pluginId?: string | string[] | RegExp;
+}) {
+ const limitedComponents = getLimitedComponentsToRender({ props, components, limit, pluginId });
+
+ if (!limitedComponents?.length) {
+ return null;
+ }
+
+ return (
+ <>
+ {limitedComponents.map((Component) => (
+
+ ))}
+ >
+ );
+}
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
index 9ed8a4765df..f0ed47f1546 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
@@ -333,10 +333,7 @@ export const defaultValueMapping = (): ValueMapping => (defaultValueMap());
// Maps text values to a color or different display text and color.
// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
export interface ValueMap {
- // TODO (@radiohead): Something broke in cog / app SDK codegen
- // And this is no longer producing valid TS / Go output
- // type: MappingType & "value"
- type: "value";
+ type: MappingType & "value";
// Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } }
options: Record;
}
@@ -346,6 +343,15 @@ export const defaultValueMap = (): ValueMap => ({
options: {},
});
+// Supported value mapping types
+// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
+// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
+// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
+// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.
+export type MappingType = "value" | "range" | "regex" | "special";
+
+export const defaultMappingType = (): MappingType => ("value");
+
// Result used as replacement with text and color when the value matches
export interface ValueMappingResult {
// Text to display when the value matches
@@ -364,10 +370,7 @@ export const defaultValueMappingResult = (): ValueMappingResult => ({
// Maps numerical ranges to a display text and color.
// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
export interface RangeMap {
- // TODO (@radiohead): Something broke in cog / app SDK codegen
- // And this is no longer producing valid TS / Go output
- // type: MappingType & "range"
- type: "range";
+ type: MappingType & "range";
// Range to match against and the result to apply when the value is within the range
options: {
// Min value of the range. It can be null which means -Infinity
@@ -391,10 +394,7 @@ export const defaultRangeMap = (): RangeMap => ({
// Maps regular expressions to replacement text and a color.
// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
export interface RegexMap {
- // TODO (@radiohead): Something broke in cog / app SDK codegen
- // And this is no longer producing valid TS / Go output
- // type: MappingType & "regex"
- type: "regex";
+ type: MappingType & "regex";
// Regular expression to match against and the result to apply when the value matches the regex
options: {
// Regular expression to match against
@@ -416,10 +416,7 @@ export const defaultRegexMap = (): RegexMap => ({
// See SpecialValueMatch to see the list of special values.
// For example, you can configure a special value mapping so that null values appear as N/A.
export interface SpecialValueMap {
- // TODO (@radiohead): Something broke in cog / app SDK codegen
- // And this is no longer producing valid TS / Go output
- // type: MappingType & "special"
- type: "special";
+ type: MappingType & "special";
options: {
// Special value to match against
match: SpecialValueMatch;
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index d48a36d31d2..e72cfe337bd 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -126,7 +126,7 @@
"slate-react": "0.22.10",
"tinycolor2": "1.6.0",
"tslib": "2.8.1",
- "uplot": "1.6.31",
+ "uplot": "1.6.32",
"uuid": "11.0.5"
},
"devDependencies": {
diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx
index 59cd9074a2d..fbb2fecb085 100644
--- a/packages/grafana-ui/src/components/Badge/Badge.tsx
+++ b/packages/grafana-ui/src/components/Badge/Badge.tsx
@@ -10,6 +10,7 @@ import { useStyles2 } from '../../themes/ThemeContext';
import { IconName } from '../../types';
import { SkeletonComponent, attachSkeleton } from '../../utils/skeleton';
import { Icon } from '../Icon/Icon';
+import { PopoverContent } from '../Tooltip';
import { Tooltip } from '../Tooltip/Tooltip';
export type BadgeColor = 'blue' | 'red' | 'green' | 'orange' | 'purple' | 'darkgrey';
@@ -18,7 +19,7 @@ export interface BadgeProps extends HTMLAttributes {
text: React.ReactNode;
color: BadgeColor;
icon?: IconName;
- tooltip?: string;
+ tooltip?: PopoverContent;
}
const BadgeComponent = React.memo(({ icon, color, text, tooltip, className, ...otherProps }) => {
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx
index 8b81be33bf0..9f13e67918b 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx
@@ -12,6 +12,24 @@ describe('ColorPickerInput', () => {
expect(screen.getByTestId('color-popover')).toBeInTheDocument();
});
+ it('should hide color popover on blur', async () => {
+ render();
+ expect(screen.queryByTestId('color-popover')).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole('textbox'));
+ expect(screen.getByTestId('color-popover')).toBeInTheDocument();
+ await userEvent.click(document.body);
+ expect(screen.queryByTestId('color-popover')).not.toBeInTheDocument();
+ });
+
+ it('should not hide color popover on blur if clicked inside the color picker', async () => {
+ render();
+ expect(screen.queryByTestId('color-popover')).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole('textbox'));
+ expect(screen.getByTestId('color-popover')).toBeInTheDocument();
+ await userEvent.click(screen.getAllByRole('slider')[0]);
+ expect(screen.queryByTestId('color-popover')).toBeInTheDocument();
+ });
+
it('should pass correct color to onChange callback', async () => {
const mockOnChange = jest.fn();
render();
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx
index add799af11c..381ad0403b2 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
-import { useState, forwardRef } from 'react';
+import { useState, forwardRef, FocusEvent } from 'react';
import { RgbaStringColorPicker } from 'react-colorful';
import { useThrottleFn } from 'react-use';
@@ -48,6 +48,14 @@ export const ColorPickerInput = forwardRef) => {
+ // Unless the user clicked inside the color picker, close it on blur
+ const isClickInPopover = document.querySelector('[data-testid="color-popover"]')?.contains(evt.relatedTarget);
+ if (!isClickInPopover) {
+ setIsOpen(false);
+ }
+ };
+
return (
setIsOpen(false)}>