Compare commits

...

72 Commits

Author SHA1 Message Date
Ashley Harrison
cc7577f940 change image tag to dev-preview-react19 2026-01-08 14:55:46 +00:00
Ashley Harrison
21ea3f2c2e fix e2e tests 2026-01-06 11:19:59 +00:00
Ashley Harrison
59d3ef2504 fix stackdriver tests 2026-01-06 10:21:14 +00:00
Ashley Harrison
ff7bab5753 fix type errors 2026-01-06 10:10:27 +00:00
Ashley Harrison
9ba65ab73f Merge branch 'main' into ash/react-19-again 2026-01-06 10:02:58 +00:00
Josh Hunt
fccece3ca0 Refactor: Remove jQuery from AppWrapper (#115842) 2026-01-06 09:58:42 +00:00
Juan Cabanas
d44cab9eaf DashboardLibrary: Add validations to visualize community dashboards (#114562)
* dashboard library check added

* community dashboard section tests in progress

* tests added

* translations added

* pagination removed

* total pages removed

* test updated. pagination removed

* filters applied

* tracking event removed to be created in another pr

* slug added so url is correclty generated

* ui fix

* improvements after review

* improvements after review

* more tests added. new logic created

* fix

* changes applied

* tests removed. pattern updated

* preset of 6 elements applied

* Improve code comments and adjust variable name based on PR feedback

* Fix unit test and add extra case for regex pattern

* Fix interaction event, we were missing contentKind on BasicProvisioned flow and datasources types were not being send

---------

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
Co-authored-by: alexandra vargas <alexa1866@gmail.com>
2026-01-06 10:38:15 +01:00
Saurabh Yadav
3d3b4dd213 Clean up packages/grafana-prometheus/src/dashboards (#115861)
* remove:Dashboard json files

* removed: dashboards from packages/grafana-prometheus/src/dashboards
2026-01-06 09:26:04 +00:00
Ashley Harrison
d97c56c51e Merge branch 'main' into ash/react-19-again 2026-01-05 15:11:45 +00:00
Ashley Harrison
4752b45f81 Merge branch 'main' into ash/react-19-again 2025-12-17 09:29:19 +00:00
Ashley Harrison
43ce2acafc "fix" loki decoupled plugin tests 2025-12-16 13:16:37 +00:00
Ashley Harrison
f2cb70b1e0 add MessageChannel mock to grafana-plugin-configs 2025-12-16 12:04:11 +00:00
Ashley Harrison
7a1938c23b Merge branch 'main' into ash/react-19-again 2025-12-16 11:48:17 +00:00
Ashley Harrison
03de5f59e6 fix @grafana/ui type errors 2025-12-16 11:26:27 +00:00
Ashley Harrison
494a663449 fix @grafana/flamegraph types 2025-12-16 11:07:30 +00:00
Ashley Harrison
d9059ca7b2 ignore react-use errors for now 2025-12-16 10:56:40 +00:00
Ashley Harrison
c254cf1387 remove unused vars 2025-12-16 10:19:25 +00:00
Ashley Harrison
ebf6ba442b add temporary publish to dockerhub step 2025-12-16 10:17:35 +00:00
Ashley Harrison
462b6354d0 fix pluginExtensions tests 2025-12-15 16:43:12 +00:00
Ashley Harrison
7f1f3c6ba6 kick CI again 2025-12-15 16:31:28 +00:00
Ashley Harrison
de6d2700b7 kick CI 2025-12-15 16:28:30 +00:00
Ashley Harrison
481dc3e630 type fixes in grafana-ui 2025-12-15 15:50:45 +00:00
Ashley Harrison
0f46f38b77 mock out testing libraries in prod builds 2025-12-15 12:56:29 +00:00
Ashley Harrison
2f3a4d0358 maybe better way to attach meta? 2025-12-12 17:05:43 +00:00
Ashley Harrison
d0aec88ca8 "fix" PublicDashboardPageProxy tests 2025-12-12 15:56:57 +00:00
Ashley Harrison
4a0e9204b3 fix explore query tests 2025-12-12 15:55:19 +00:00
Ashley Harrison
12c6e9615f "fix" NewReceiverView tests 2025-12-12 15:45:53 +00:00
Ashley Harrison
7a0d7c5dec "fix" GroupedView tests 2025-12-12 15:41:56 +00:00
Ashley Harrison
b035732a85 kick CI 2025-12-12 13:56:58 +00:00
Ashley Harrison
05ef468b41 Merge branch 'main' into ash/react-19-again 2025-12-12 11:45:59 +00:00
Ashley Harrison
730f10597a "fix" useMoveRuleFromRuleGroup tests 2025-12-12 11:38:50 +00:00
Ashley Harrison
caff0e2d1e "fix" MuteTimings tests 2025-12-12 11:34:28 +00:00
Ashley Harrison
f10a494369 "fix" ImportToGMARules tests 2025-12-12 11:30:59 +00:00
Ashley Harrison
8d42d4a079 "fix" SignupInvited tests 2025-12-12 11:26:04 +00:00
Ashley Harrison
720f038981 "fix" VersionsSettings tests 2025-12-12 11:24:00 +00:00
Ashley Harrison
b380ce2bfd fix CloneRuleEditor tests 2025-12-12 11:19:53 +00:00
Ashley Harrison
68a83b73c9 "fix" provisioningwizard tests 2025-12-12 11:16:05 +00:00
Ashley Harrison
3808ddf948 "fix" some CorrelationsPage tests 2025-12-12 11:08:04 +00:00
Ashley Harrison
f1d654d2e3 fix some DashboardScenePage tests 2025-12-12 10:44:59 +00:00
Ashley Harrison
b0798f24c5 "fix" GrafanaModifyExport test 2025-12-12 10:30:06 +00:00
Ashley Harrison
4c90d10281 fix AnnotationsStep changes 2025-12-10 10:24:32 +00:00
Ashley Harrison
96614c4eca fix QueryEditor test 2025-12-10 10:18:38 +00:00
Ashley Harrison
fd4a97e49e fix split test 2025-12-10 09:46:28 +00:00
Ashley Harrison
68e0ed782c fix type error in geomap panel 2025-12-09 10:39:26 +00:00
Ashley Harrison
5fbbf2ac4a fix typings on LogRowMenuCell 2025-12-09 10:05:29 +00:00
Ashley Harrison
e97d48d86b add resolution for react + react-dom 2025-12-09 09:49:20 +00:00
Ashley Harrison
74c656713a "fix" SendResetMailPage tests 2025-12-08 17:07:46 +00:00
Ashley Harrison
470cd869f3 "fix" VerifyEmailPage tests 2025-12-08 17:06:18 +00:00
Ashley Harrison
3ef28b727f fix useStatelessReducer tests 2025-12-08 16:59:10 +00:00
Ashley Harrison
afe54f6739 fix ElasticsearchQueryContext test 2025-12-08 16:58:08 +00:00
Ashley Harrison
f7d8fd4986 "fix" loglinedetailstrace tests 2025-12-08 16:53:57 +00:00
Ashley Harrison
c73db56467 fix usePauseAlertRule tests 2025-12-08 16:02:25 +00:00
Ashley Harrison
37bd5ded3a fix LoginPage tests 2025-12-08 15:56:36 +00:00
Ashley Harrison
418c1a4d5a "fix" BulkDeleteProvisionedResource tests 2025-12-08 15:42:24 +00:00
Ashley Harrison
d3e807d6e2 Merge branch 'main' into ash/react-19-again 2025-12-08 15:11:45 +00:00
Ashley Harrison
03a044a9a0 fix SharedPreferences tests 2025-12-08 13:56:10 +00:00
Ashley Harrison
e861318c2d almost fix PublicDashboardScenePage 2025-12-08 13:52:42 +00:00
Ashley Harrison
35633b756d temporary flushSync to fix some tests 2025-12-08 12:26:02 +00:00
Ashley Harrison
eb77bf89df maybe fix some RTL tests 2025-12-05 14:57:25 +00:00
Ashley Harrison
636c62862d Merge branch 'main' into ash/react-19-again 2025-12-04 16:38:41 +00:00
Ashley Harrison
737ee7c7bd Merge branch 'main' into ash/react-19-again 2025-12-04 16:21:30 +00:00
Ashley Harrison
13b5c3f974 add resolution for react-is 2025-12-04 13:19:04 +00:00
Ashley Harrison
1915a92eb2 upgrade rc-cascader 2025-12-04 12:02:24 +00:00
Ashley Harrison
94cad60654 fix a few more src test errors 2025-12-02 17:40:33 +00:00
Ashley Harrison
9d9085075b undo mock changes and handle in component 2025-12-02 17:32:04 +00:00
Ashley Harrison
efeac25952 fix some unit tests 2025-12-02 16:51:42 +00:00
Ashley Harrison
0da94b11ee fix lots of type errors 2025-12-02 15:04:28 +00:00
Ashley Harrison
9aa86eb056 some more type fixes 2025-11-28 13:28:31 +00:00
Ashley Harrison
f6107150e0 Merge branch 'main' into ash/react-19-again 2025-11-28 11:30:45 +00:00
Ashley Harrison
cb90eddf84 fix ref type errors 2025-11-27 15:51:01 +00:00
Ashley Harrison
141ed7bdbf add MessageChannel mock 2025-11-27 11:20:26 +00:00
Ashley Harrison
d2bf550499 bump to latest react versions 2025-11-27 11:00:22 +00:00
152 changed files with 2225 additions and 4225 deletions

View File

@@ -192,6 +192,30 @@ jobs:
-f "output[summary]=${IMAGE}" \
-f "output[text]=${IMAGE}"
# TODO remove this when delivering
# This will push the temporary docker image to dockerhub
push-docker-image-to-dockerhub:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
needs:
- build-grafana
steps:
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
with:
name: grafana-docker-tar-gz
path: .
- uses: grafana/shared-workflows/actions/dockerhub-login@dockerhub-login/v1.0.2
- name: Load & Push Docker image
run: |
set -euo pipefail
LOADED_IMAGE_NAME=$(docker load -i grafana.docker.tar.gz | sed 's/Loaded image: //g')
DOCKER_IMAGE="grafana/grafana:dev-preview-react19"
docker tag "${LOADED_IMAGE_NAME}" "${DOCKER_IMAGE}"
docker push "${DOCKER_IMAGE}"
run-e2e-tests:
needs:
- build-grafana

View File

@@ -16,8 +16,8 @@
"@types/lodash": "4.17.7",
"@types/node": "24.9.2",
"@types/prismjs": "1.26.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/semver": "7.5.8",
"@types/uuid": "9.0.8",
"glob": "10.5.0",
@@ -37,8 +37,8 @@
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-router-dom": "^6.22.0",
"rxjs": "7.8.1",
"tslib": "2.6.3"

View File

@@ -16,8 +16,8 @@
"@types/lodash": "4.17.7",
"@types/node": "24.9.2",
"@types/prismjs": "1.26.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/semver": "7.5.8",
"@types/uuid": "9.0.8",
"glob": "10.4.1",
@@ -37,8 +37,8 @@
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-router-dom": "^6.22.0",
"rxjs": "7.8.1",
"tslib": "2.6.3"

View File

@@ -14,9 +14,6 @@ test.describe(
test('Graph panel is auto-migrated', async ({ gotoDashboardPage, page }) => {
await gotoDashboardPage({ uid: DASHBOARD_ID });
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
await expect(page.getByTestId(UPLOT_MAIN_DIV_SELECTOR).first()).toBeHidden();
await gotoDashboardPage({ uid: DASHBOARD_ID });
await expect(page.getByTestId(UPLOT_MAIN_DIV_SELECTOR).first()).toBeVisible();
});
@@ -24,9 +21,6 @@ test.describe(
test('Annotation markers exist for time regions', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_ID });
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
await expect(page.getByTestId(UPLOT_MAIN_DIV_SELECTOR).first()).toBeHidden();
await gotoDashboardPage({ uid: DASHBOARD_ID });
// Check Business Hours panel
const businessHoursPanel = dashboardPage.getByGrafanaSelector(

View File

@@ -140,8 +140,8 @@
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.3.0",
"@types/pluralize": "^0.0.33",
"@types/prismjs": "1.26.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-grid-layout": "1.3.5",
"@types/react-highlight-words": "0.20.0",
"@types/react-resizable": "3.0.8",
@@ -239,7 +239,7 @@
"prettier": "3.6.2",
"prom-client": "^15.1.3",
"publint": "^0.3.12",
"react-refresh": "0.14.0",
"react-refresh": "0.18.0",
"react-select-event": "5.5.1",
"redux-mock-store": "1.5.5",
"rimraf": "6.0.1",
@@ -389,9 +389,9 @@
"pluralize": "^8.0.0",
"prismjs": "1.30.0",
"re-resizable": "6.11.2",
"react": "18.3.1",
"react": "19.2.1",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "18.3.1",
"react-dom": "19.2.1",
"react-draggable": "4.5.0",
"react-dropzone": "^14.2.3",
"react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch",
@@ -462,7 +462,10 @@
"js-yaml@npm:4.1.0": "^4.1.0",
"js-yaml@npm:=4.1.0": "^4.1.0",
"nodemailer": "7.0.11",
"@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch"
"@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch",
"pretty-format/react-is": "19.0.0",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"workspaces": {
"packages": [

View File

@@ -70,13 +70,13 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/tinycolor2": "^1",
"i18next": "^25.5.2",
"i18next-cli": "^1.24.22",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-redux": "^9.2.0",
"rimraf": "^6.0.1",
"rollup": "^4.22.4",

View File

@@ -91,12 +91,12 @@
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/papaparse": "5.3.16",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/tinycolor2": "1.4.6",
"esbuild": "0.25.8",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"rimraf": "6.0.1",
"rollup": "^4.22.4",
"rollup-plugin-esbuild": "6.2.1",

View File

@@ -57,7 +57,7 @@
"@leeoniya/ufuzzy": "1.0.19",
"d3": "^7.8.5",
"lodash": "4.17.21",
"react": "18.3.1",
"react": "19.2.0",
"react-use": "17.6.0",
"react-virtualized-auto-sizer": "1.0.26",
"tinycolor2": "1.6.0",
@@ -80,7 +80,7 @@
"@types/jest": "^29.5.4",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/react": "18.3.18",
"@types/react": "19.2.7",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/tinycolor2": "1.4.6",
"babel-jest": "29.7.0",

View File

@@ -22,7 +22,7 @@ import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './c
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
type RenderOptions = {
canvasRef: RefObject<HTMLCanvasElement>;
canvasRef: RefObject<HTMLCanvasElement | null>;
data: FlameGraphDataContainer;
root: LevelItem;
direction: 'children' | 'parents';
@@ -373,7 +373,7 @@ function useColorFunction(
);
}
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement | null>, wrapperWidth: number, numberOfLevels: number) {
const [ctx, setCtx] = useState<CanvasRenderingContext2D>();
useEffect(() => {

View File

@@ -63,7 +63,7 @@
"react-i18next": "^15.0.0"
},
"devDependencies": {
"@types/react": "18.3.18",
"@types/react": "19.2.7",
"rollup": "^4.22.4",
"rollup-plugin-copy": "3.5.0",
"typescript": "5.9.2"

View File

@@ -36,10 +36,10 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "^29.5.4",
"@types/node": "24.10.1",
"@types/react": "18.3.18",
"@types/react": "19.2.7",
"@types/systemjs": "6.15.3",
"jest": "^29.6.4",
"react": "18.3.1",
"react": "19.2.0",
"ts-jest": "29.4.0",
"ts-node": "10.9.2",
"typescript": "5.9.2"

View File

@@ -84,3 +84,19 @@ global.ResizeObserver = class ResizeObserver {
this.#isObserving = false;
}
};
global.MessageChannel = jest.fn().mockImplementation(() => {
let onmessage;
return {
port1: {
set onmessage(cb) {
onmessage = cb;
},
},
port2: {
postMessage: (data) => {
onmessage?.({ data });
},
},
};
});

View File

@@ -4,7 +4,7 @@
"private": true,
"version": "12.4.0-pre",
"dependencies": {
"react": "18.3.1",
"react": "19.2.0",
"terser-webpack-plugin": "5.3.14",
"tslib": "2.8.1"
},
@@ -15,7 +15,7 @@
"@swc/helpers": "^0.5.0",
"@swc/jest": "^0.2.26",
"@types/eslint": "9.6.1",
"@types/react": "18.3.18",
"@types/react": "19.2.7",
"@types/webpack-bundle-analyzer": "^4.7.0",
"copy-webpack-plugin": "13.0.0",
"eslint": "9.32.0",

View File

@@ -64,8 +64,8 @@
"@reduxjs/toolkit": "2.10.1",
"@types/debounce-promise": "3.1.9",
"@types/lodash": "4.17.20",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-highlight-words": "0.20.0",
"@types/react-window": "1.8.8",
"@types/semver": "7.7.1",
@@ -101,8 +101,8 @@
"i18next-cli": "^1.24.22",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-select-event": "5.5.1",
"rimraf": "6.0.1",
"rollup": "^4.22.4",

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { flushSync } from 'react-dom';
import { useDebounce } from 'react-use';
import { TimeRange } from '@grafana/data';
@@ -225,7 +226,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
newSelectedMetric === '' ? undefined : selector
);
setMetrics(fetchedMetrics);
// TODO why?!
flushSync(() => {
setMetrics(fetchedMetrics);
});
setSelectedMetric(newSelectedMetric);
setLabelKeys(fetchedLabelKeys);
setIsLoadingLabelKeys(false);

File diff suppressed because it is too large Load Diff

View File

@@ -1,834 +0,0 @@
{
"_comment": "Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json",
"__inputs": [
{
"name": "DS_GDEV-PROMETHEUS",
"label": "gdev-prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "8.1.0-pre"
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "stat",
"name": "Stat",
"version": ""
},
{
"type": "panel",
"id": "text",
"name": "Text",
"version": ""
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": null,
"iteration": 1624859749459,
"links": [
{
"icon": "info",
"tags": [],
"targetBlank": true,
"title": "Grafana Docs",
"tooltip": "",
"type": "link",
"url": "https://grafana.com/docs/grafana/latest/"
},
{
"icon": "info",
"tags": [],
"targetBlank": true,
"title": "Prometheus Docs",
"type": "link",
"url": "http://prometheus.io/docs/introduction/overview/"
}
],
"panels": [
{
"cacheTimeout": null,
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 1,
"mappings": [
{
"options": {
"match": "null",
"result": {
"text": "N/A"
}
},
"type": "special"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 0,
"y": 0
},
"id": 5,
"interval": null,
"links": [],
"maxDataPoints": 100,
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "(time() - process_start_time_seconds{job=\"prometheus\", instance=~\"$node\"})",
"intervalFactor": 2,
"refId": "A"
}
],
"title": "Uptime",
"type": "stat"
},
{
"cacheTimeout": null,
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "rgb(31, 120, 193)",
"mode": "fixed"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "rgba(50, 172, 45, 0.97)",
"value": null
},
{
"color": "rgba(237, 129, 40, 0.89)",
"value": 1
},
{
"color": "rgba(245, 54, 54, 0.9)",
"value": 5
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 6,
"y": 0
},
"id": 6,
"interval": null,
"links": [],
"maxDataPoints": 100,
"options": {
"colorMode": "none",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_local_storage_memory_series{instance=~\"$node\"}",
"intervalFactor": 2,
"refId": "A"
}
],
"title": "Local Storage Memory Series",
"type": "stat"
},
{
"cacheTimeout": null,
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"text": "Empty"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "rgba(50, 172, 45, 0.97)",
"value": null
},
{
"color": "rgba(237, 129, 40, 0.89)",
"value": 500
},
{
"color": "rgba(245, 54, 54, 0.9)",
"value": 4000
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 12,
"y": 0
},
"id": 7,
"interval": null,
"links": [],
"maxDataPoints": 100,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_local_storage_indexing_queue_length{instance=~\"$node\"}",
"intervalFactor": 2,
"refId": "A"
}
],
"title": "Internal Storage Queue Length",
"type": "stat"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 5,
"w": 6,
"x": 18,
"y": 0
},
"id": 9,
"links": [],
"options": {
"content": "<span style=\"font-family: 'Open Sans', 'Helvetica Neue', Helvetica; font-size: 25px;vertical-align: text-top;color: #bbbfc2;margin-left: 10px;\">Prometheus</span>\n\n<p style=\"margin-top: 10px;\">You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the <a href=\"https://grafana.com/\">Grafana</a> and <a href=\"http://prometheus.io/\">Prometheus</a> projects.</p>",
"mode": "html"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "prometheus"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#C15C17",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "{instance=\"localhost:9090\",job=\"prometheus\"}"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#C15C17",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 6,
"w": 18,
"x": 0,
"y": 5
},
"id": 3,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "rate(prometheus_local_storage_ingested_samples_total{instance=~\"$node\"}[5m])",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{job}}",
"metric": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Samples ingested (rate-5m)",
"type": "timeseries"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 6,
"w": 4,
"x": 18,
"y": 5
},
"id": 8,
"links": [],
"options": {
"content": "#### Samples Ingested\nThis graph displays the count of samples ingested by the Prometheus server, as measured over the last 5 minutes, per time series in the range vector. When troubleshooting an issue on IRC or GitHub, this is often the first stat requested by the Prometheus team. ",
"mode": "markdown"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "prometheus"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#F9BA8F",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#F9BA8F",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 7,
"w": 10,
"x": 0,
"y": 11
},
"id": 2,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "rate(prometheus_target_interval_length_seconds_count{instance=~\"$node\"}[5m])",
"intervalFactor": 2,
"legendFormat": "{{job}}",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Target Scrapes (last 5m)",
"type": "timeseries"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 8,
"x": 10,
"y": 11
},
"id": 14,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_target_interval_length_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{quantile}} ({{interval}})",
"metric": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Scrape Duration",
"type": "timeseries"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 7,
"w": 6,
"x": 18,
"y": 11
},
"id": 11,
"links": [],
"options": {
"content": "#### Scrapes\nPrometheus scrapes metrics from instrumented jobs, either directly or via an intermediary push gateway for short-lived jobs. Target scrapes will show how frequently targets are scraped, as measured over the last 5 minutes, per time series in the range vector. Scrape Duration will show how long the scrapes are taking, with percentiles available as series. ",
"mode": "markdown"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 18,
"x": 0,
"y": 18
},
"id": 12,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_evaluator_duration_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{quantile}}",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Rule Eval Duration",
"type": "timeseries"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 7,
"w": 6,
"x": 18,
"y": 18
},
"id": 15,
"links": [],
"options": {
"content": "#### Rule Evaluation Duration\nThis graph panel plots the duration for all evaluations to execute. The 50th percentile, 90th percentile and 99th percentile are shown as three separate series to help identify outliers that may be skewing the data.",
"mode": "markdown"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
}
],
"refresh": false,
"revision": "1.0",
"schemaVersion": 30,
"tags": ["prometheus"],
"templating": {
"list": [
{
"allValue": null,
"current": {},
"datasource": "${DS_GDEV-PROMETHEUS}",
"definition": "",
"description": null,
"error": null,
"hide": 0,
"includeAll": false,
"label": "HOST:",
"multi": false,
"name": "node",
"options": [],
"query": {
"query": "label_values(prometheus_build_info, instance)",
"refId": "gdev-prometheus-node-Variable-Query"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"tagValuesQuery": "",
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-5m",
"to": "now"
},
"timepicker": {
"now": true,
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Prometheus Stats",
"uid": "rpfmFFz7z",
"version": 2
}

View File

@@ -99,7 +99,7 @@ describe('MetricsLabelsSection', () => {
onBlur: onBlur,
variableEditor: undefined,
}),
expect.anything()
undefined
);
});
@@ -124,7 +124,7 @@ describe('MetricsLabelsSection', () => {
labelsFilters: defaultQuery.labels,
variableEditor: undefined,
}),
expect.anything()
undefined
);
});

View File

@@ -78,12 +78,12 @@
"@types/history": "4.7.11",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"esbuild": "0.25.8",
"lodash": "4.17.21",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"rimraf": "6.0.1",
"rollup": "^4.22.4",
"rollup-plugin-esbuild": "6.2.1",

View File

@@ -25,8 +25,8 @@
"@react-awesome-query-builder/ui": "6.6.15",
"immutable": "5.1.4",
"lodash": "4.17.21",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-select": "5.10.2",
"react-use": "17.6.0",
"react-virtualized-auto-sizer": "1.0.26",
@@ -43,8 +43,8 @@
"@types/jest": "^29.5.4",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/systemjs": "6.15.3",
"@types/uuid": "10.0.0",

View File

@@ -75,6 +75,7 @@
"@hello-pangea/dnd": "18.0.1",
"@monaco-editor/react": "4.7.0",
"@popperjs/core": "2.11.8",
"@rc-component/cascader": "1.9.0",
"@rc-component/drawer": "1.3.0",
"@rc-component/picker": "1.7.1",
"@rc-component/slider": "1.0.1",
@@ -105,7 +106,6 @@
"monaco-editor": "0.34.1",
"ol": "10.7.0",
"prismjs": "1.30.0",
"rc-cascader": "3.34.0",
"react-calendar": "^6.0.0",
"react-colorful": "5.6.1",
"react-custom-scrollbars-2": "4.5.0",
@@ -167,9 +167,9 @@
"@types/mock-raf": "1.0.6",
"@types/node": "24.10.1",
"@types/prismjs": "1.26.5",
"@types/react": "18.3.18",
"@types/react": "19.2.7",
"@types/react-color": "3.0.13",
"@types/react-dom": "18.3.5",
"@types/react-dom": "19.2.3",
"@types/react-highlight-words": "0.20.0",
"@types/react-transition-group": "4.4.12",
"@types/react-window": "1.8.8",
@@ -190,8 +190,8 @@
"msw": "^2.10.2",
"msw-storybook-addon": "^2.0.5",
"process": "^0.11.10",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-select-event": "^5.1.0",
"rimraf": "6.0.1",
"rollup": "^4.22.4",

View File

@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import RCCascader, { FieldNames } from 'rc-cascader';
import RCCascader, { FieldNames } from '@rc-component/cascader';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
@@ -47,7 +47,7 @@ export const ButtonCascader = (props: ButtonCascaderProps) => {
<RCCascader
onChange={onChangeCascader(onChange)}
loadData={onLoadDataCascader(loadData)}
dropdownClassName={cx(cascaderStyles.dropdown, styles.popup)}
popupClassName={cx(cascaderStyles.dropdown, styles.popup)}
{...rest}
expandIcon={null}
>

View File

@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import RCCascader from '@rc-component/cascader';
import memoize from 'micro-memoize';
import RCCascader from 'rc-cascader';
import { PureComponent } from 'react';
import * as React from 'react';
@@ -279,7 +279,7 @@ class UnthemedCascader extends PureComponent<CascaderProps, CascaderState> {
expandIcon={null}
open={this.props.alwaysOpen}
disabled={disabled}
dropdownClassName={styles.dropdown}
popupClassName={styles.dropdown}
>
<div className={disableDivFocus}>
<Input

View File

@@ -1,4 +1,4 @@
import { BaseOptionType as RCCascaderOption, CascaderProps } from 'rc-cascader';
import { BaseOptionType as RCCascaderOption, CascaderProps } from '@rc-component/cascader';
import { CascaderOption } from './Cascader';

View File

@@ -18,7 +18,7 @@ interface ComboboxListProps<T extends string | number> {
options: Array<ComboboxOption<T>>;
highlightedIndex: number | null;
selectedItems?: Array<ComboboxOption<T>>;
scrollRef: React.RefObject<HTMLDivElement>;
scrollRef: React.RefObject<HTMLDivElement | null>;
getItemProps: UseComboboxPropGetters<ComboboxOption<T>>['getItemProps'];
enableAllOption?: boolean;
isMultiSelect?: boolean;

View File

@@ -1,5 +1,5 @@
import { autoUpdate, autoPlacement, size, useFloating } from '@floating-ui/react';
import { useMemo, useRef, useState } from 'react';
import { CSSProperties, type RefObject, useMemo, useRef, useState } from 'react';
import { BOUNDARY_ELEMENT_ID } from '../../utils/floating';
import { measureText } from '../../utils/measureText';
@@ -21,7 +21,21 @@ const POPOVER_PADDING = 16;
const SCROLL_CONTAINER_PADDING = 8;
export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>, isOpen: boolean) => {
interface UseComboboxFloatReturn {
inputRef: RefObject<HTMLInputElement | null>;
floatingRef: RefObject<HTMLDivElement | null>;
scrollRef: RefObject<HTMLDivElement | null>;
floatStyles: CSSProperties & {
width: number;
maxWidth: number;
maxHeight: number;
};
}
export const useComboboxFloat = (
items: Array<ComboboxOption<string | number>>,
isOpen: boolean
): UseComboboxFloatReturn => {
const inputRef = useRef<HTMLInputElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);

View File

@@ -10,7 +10,7 @@ import { useStyles2 } from '../../themes/ThemeContext';
import { List } from '../List/List';
interface DataLinkSuggestionsProps {
activeRef?: React.RefObject<HTMLDivElement>;
activeRef?: React.RefObject<HTMLDivElement | null>;
suggestions: VariableSuggestion[];
activeIndex: number;
onSuggestionSelect: (suggestion: VariableSuggestion) => void;
@@ -103,7 +103,7 @@ DataLinkSuggestions.displayName = 'DataLinkSuggestions';
interface DataLinkSuggestionsListProps extends DataLinkSuggestionsProps {
label: string;
activeIndexOffset: number;
activeRef?: React.RefObject<HTMLDivElement>;
activeRef?: React.RefObject<HTMLDivElement | null>;
}
const DataLinkSuggestionsList = React.memo(

View File

@@ -13,7 +13,7 @@ describe('useListFocus', () => {
const testid = 'test';
const getListElement = (
ref: RefObject<HTMLUListElement>,
ref: RefObject<HTMLUListElement | null>,
handleKeys?: (event: KeyboardEvent) => void,
onClick?: () => void
) => (

View File

@@ -7,7 +7,7 @@ const CAUGHT_KEYS = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab'];
/** @internal */
export interface UseListFocusProps {
localRef: RefObject<HTMLUListElement>;
localRef: RefObject<HTMLUListElement | null>;
options: TimeOption[];
}

View File

@@ -1,6 +1,6 @@
import { RefObject, useRef } from 'react';
export function useFocus(): [RefObject<HTMLInputElement>, () => void] {
export function useFocus(): [RefObject<HTMLInputElement | null>, () => void] {
const ref = useRef<HTMLInputElement>(null);
const setFocus = () => {
ref.current && ref.current.focus();

View File

@@ -13,7 +13,7 @@ describe('useMenuFocus', () => {
const testid = 'test';
const getMenuElement = (
ref: RefObject<HTMLDivElement>,
ref: RefObject<HTMLDivElement | null>,
handleKeys?: (event: KeyboardEvent) => void,
handleFocus?: () => void,
onClick?: () => void

View File

@@ -6,7 +6,7 @@ const UNFOCUSED = -1;
/** @internal */
export interface UseMenuFocusProps {
localRef: RefObject<HTMLDivElement>;
localRef: RefObject<HTMLDivElement | null>;
isMenuOpen?: boolean;
close?: () => void;
onOpen?: (focusOnItem: (itemId: number) => void) => void;

View File

@@ -1,11 +1,10 @@
import { HTMLAttributes, PropsWithChildren, type JSX } from 'react';
import * as React from 'react';
import { createElement, type HTMLAttributes, type PropsWithChildren, type HTMLElementType, type JSX } from 'react';
import { textUtil } from '@grafana/data';
export interface RenderUserContentAsHTMLProps<T = HTMLSpanElement>
extends Omit<HTMLAttributes<T>, 'dangerouslySetInnerHTML'> {
component?: keyof React.ReactHTML;
component?: HTMLElementType;
content: string;
}
@@ -19,7 +18,7 @@ export function RenderUserContentAsHTML<T>({
content,
...rest
}: PropsWithChildren<RenderUserContentAsHTMLProps<T>>): JSX.Element {
return React.createElement(component || 'span', {
return createElement(component || 'span', {
dangerouslySetInnerHTML: { __html: textUtil.sanitize(content) },
...rest,
});

View File

@@ -20,7 +20,7 @@ export interface TableCellTooltipProps {
field: Field;
getActions: (field: Field, rowIdx: number) => ActionModel[];
getTextColorForBackground: (bgColor: string) => string;
gridRef: RefObject<DataGridHandle>;
gridRef: RefObject<DataGridHandle | null>;
height: number;
placement?: TableCellTooltipPlacement;
renderer: TableCellRenderer;

View File

@@ -463,7 +463,7 @@ export function useColumnResize(
return dataGridResizeHandler;
}
export function useScrollbarWidth(ref: RefObject<DataGridHandle>, height: number) {
export function useScrollbarWidth(ref: RefObject<DataGridHandle | null>, height: number) {
const [scrollbarWidth, setScrollbarWidth] = useState(0);
useLayoutEffect(() => {

View File

@@ -49,7 +49,7 @@ interface RowsListProps {
listHeight: number;
width: number;
cellHeight?: TableCellHeight;
listRef: React.RefObject<VariableSizeList>;
listRef: React.RefObject<VariableSizeList | null>;
tableState: TableState;
tableStyles: TableStyles;
nestedDataField?: Field;

View File

@@ -135,7 +135,7 @@ export const Table = memo((props: Props) => {
// `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns
// `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time
// we initialize `useTableStateReducer`.
const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>();
const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>(null);
// Internal react table state reducer
const stateReducer = useTableStateReducer({

View File

@@ -14,8 +14,8 @@ import { GrafanaTableState } from './types';
Select the scrollbar element from the VariableSizeList scope
*/
export function useFixScrollbarContainer(
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement>,
tableDivRef: React.RefObject<HTMLDivElement>
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement | null>,
tableDivRef: React.RefObject<HTMLDivElement | null>
) {
useEffect(() => {
if (variableSizeListScrollbarRef.current && tableDivRef.current) {
@@ -43,7 +43,7 @@ export function useFixScrollbarContainer(
*/
export function useResetVariableListSizeCache(
extendedState: GrafanaTableState,
listRef: React.RefObject<VariableSizeList>,
listRef: React.RefObject<VariableSizeList | null>,
data: DataFrame,
hasUniqueId: boolean
) {

View File

@@ -19,7 +19,7 @@ interface EventsCanvasProps {
}
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
const plotInstance = useRef<uPlot>();
const plotInstance = useRef<uPlot>(null);
// render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change
// so we need to force the re-render when the draw hook was performed by uPlot
const [renderToken, setRenderToken] = useState(0);

View File

@@ -140,7 +140,7 @@ export const TooltipPlugin2 = ({
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState);
const sizeRef = useRef<TooltipContainerSize>();
const sizeRef = useRef<TooltipContainerSize>(null);
const styles = useStyles2(getStyles, maxWidth);
const renderRef = useRef(render);

View File

@@ -96,7 +96,7 @@ export interface GraphNGState {
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
static contextType = PanelContextRoot;
panelContext: PanelContext = {} as PanelContext;
private plotInstance: React.RefObject<uPlot>;
private plotInstance: React.RefObject<uPlot | null>;
private subscription = new Subscription();

View File

@@ -53,7 +53,7 @@ export const TooltipPlugin = ({
renderTooltip,
...otherProps
}: TooltipPluginProps) => {
const plotInstance = useRef<uPlot>();
const plotInstance = useRef<uPlot>(null);
const theme = useTheme2();
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);

View File

@@ -19,7 +19,7 @@ export function useDelayedSwitch(value: boolean, options: DelayOptions = {}): bo
const { duration = 250, delay = 250 } = options;
const [delayedValue, setDelayedValue] = useState(value);
const onStartTime = useRef<Date | undefined>();
const onStartTime = useRef<Date | undefined>(undefined);
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined;

View File

@@ -57,7 +57,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
async componentDidMount() {
this.setState({ ready: true });
$('.preloader').remove();
this.removePreloader();
// clear any old icon caches
const cacheKeys = (await window.caches?.keys()) ?? [];
@@ -68,6 +68,15 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
}
}
removePreloader() {
const preloader = document.querySelector('.preloader');
if (preloader) {
preloader.remove();
} else {
console.warn('Preloader element not found');
}
}
renderRoute = (route: RouteDescriptor) => {
return (
<Route

View File

@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
@@ -34,7 +35,10 @@ export const ForgottenPassword = () => {
const sendEmail = async (formModel: EmailDTO) => {
const res = await getBackendSrv().post('/api/user/password/send-reset-email', formModel);
if (res) {
setEmailSent(true);
// TODO why?
flushSync(() => {
setEmailSent(true);
});
}
};

View File

@@ -109,7 +109,7 @@ const defaultMatchers = {
* "Time as X" core component, expects ascending x
*/
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
private plotInstance: React.RefObject<uPlot>;
private plotInstance: React.RefObject<uPlot | null>;
constructor(props: GraphNGProps) {
super(props);

View File

@@ -1,3 +1,4 @@
import { act } from '@testing-library/react';
import { comboboxTestSetup } from 'test/helpers/comboboxTestSetup';
import { getSelectParent, selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
@@ -29,7 +30,9 @@ const selectComboboxOptionInTest = async (input: HTMLElement, optionOrOptions: s
};
const setup = async () => {
const view = render(<SharedPreferences resourceUri="user" preferenceType="user" />);
// TODO investigate why we need act
// see https://github.com/testing-library/react-testing-library/issues/1375
const view = await act(() => render(<SharedPreferences resourceUri="user" preferenceType="user" />));
const themeSelect = await screen.findByRole('combobox', { name: 'Interface theme' });
await waitFor(() => expect(themeSelect).not.toBeDisabled());
return view;

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
@@ -25,7 +26,10 @@ export const VerifyEmail = () => {
getBackendSrv()
.post('/api/user/signup', formModel)
.then(() => {
setEmailSent(true);
// TODO why?
flushSync(() => {
setEmailSent(true);
});
})
.catch((err) => {
const msg = err.data?.message || err;

View File

@@ -1,3 +1,4 @@
import { act, fireEvent } from '@testing-library/react';
import { InitialEntry } from 'history';
import { last } from 'lodash';
import { Route, Routes } from 'react-router-dom-v5-compat';
@@ -144,8 +145,11 @@ const fillOutForm = async ({
};
const saveMuteTiming = async () => {
const user = userEvent.setup();
await user.click(await screen.findByText(/save time interval/i));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
const button = await screen.findByText(/save time interval/i);
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(button));
};
setupMswServer();

View File

@@ -2,7 +2,7 @@ import { HTMLAttributes } from 'react';
import { Button, IconSize } from '@grafana/ui';
interface Props extends HTMLAttributes<HTMLButtonElement> {
interface Props extends Omit<HTMLAttributes<HTMLButtonElement>, 'onToggle'> {
isCollapsed: boolean;
onToggle: (isCollapsed: boolean) => void;
// Todo: this should be made compulsory for a11y purposes

View File

@@ -1,7 +1,8 @@
import { act, fireEvent } from '@testing-library/react';
import * as React from 'react';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Props } from 'react-virtualized-auto-sizer';
import { render, userEvent, waitFor, waitForElementToBeRemoved } from 'test/test-utils';
import { render, waitFor, waitForElementToBeRemoved } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { mockExportApi, setupMswServer } from '../../mockApi';
@@ -72,14 +73,15 @@ describe('GrafanaModifyExport', () => {
json: 'Json Export Content',
});
const user = userEvent.setup();
renderModifyExport(grafanaRulerRule.grafana_alert.uid);
await waitForElementToBeRemoved(() => ui.loading.get());
expect(await ui.form.nameInput.find()).toHaveValue('Grafana-rule');
await user.click(ui.exportButton.get());
// TODO investigate why we need act
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(ui.exportButton.get()));
const drawer = await ui.exportDrawer.dialog.find();
expect(drawer).toBeInTheDocument();

View File

@@ -1,3 +1,4 @@
import { act, fireEvent } from '@testing-library/react';
import { render, testWithFeatureToggles, waitFor } from 'test/test-utils';
import { byLabelText, byRole } from 'testing-library-selector';
@@ -156,7 +157,10 @@ groups:
await user.click(await ui.dsImport.mimirDsOption.find());
// Click the import button
await user.click(ui.importButton.get());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(ui.importButton.get()));
// Verify confirmation dialog appears
expect(await ui.confirmationModal.find()).toBeInTheDocument();

View File

@@ -1,3 +1,4 @@
import { act, fireEvent } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { render, screen } from 'test/test-utils';
import { byPlaceholderText, byRole, byTestId } from 'testing-library-selector';
@@ -65,7 +66,10 @@ describe('new receiver', () => {
await user.type(email, 'tester@grafana.com');
// try to test the contact point
await user.click(await ui.testContactPointButton.find());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(async () => fireEvent.click(await ui.testContactPointButton.find()));
expect(await ui.testContactPointModal.find()).toBeInTheDocument();

View File

@@ -1,3 +1,4 @@
import { act } from '@testing-library/react';
import type { JSX } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { render, screen, within } from 'test/test-utils';
@@ -301,15 +302,20 @@ describe('AnnotationsField', function () {
})
);
render(
<FormWrapper
formValues={{
annotations: [
{ key: Annotation.dashboardUID, value: 'dash-test-uid' },
{ key: Annotation.panelID, value: '1' },
],
}}
/>
// TODO investigate why we need act
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act
await act(() =>
render(
<FormWrapper
formValues={{
annotations: [
{ key: Annotation.dashboardUID, value: 'dash-test-uid' },
{ key: Annotation.panelID, value: '1' },
],
}}
/>
)
);
expect(await ui.dashboardAnnotation.find()).toBeInTheDocument();

View File

@@ -479,7 +479,7 @@ describe('RuleViewer', () => {
expect.objectContaining({
ruleUid: 'test-rule-uid',
}),
expect.any(Object)
undefined
);
expect(screen.getByTestId('enrichment-section')).toBeInTheDocument();
});
@@ -500,7 +500,7 @@ describe('RuleViewer', () => {
expect.objectContaining({
ruleUid: 'test-rule-uid',
}),
expect.any(Object)
undefined
);
expect(screen.getByTestId('enrichment-section')).toBeInTheDocument();
});

View File

@@ -1,3 +1,4 @@
import { act, fireEvent } from '@testing-library/react';
import { produce } from 'immer';
import { render } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector';
@@ -58,7 +59,7 @@ describe('Moving a Grafana managed rule', () => {
const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
const { user } = render(
render(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
@@ -66,7 +67,10 @@ describe('Moving a Grafana managed rule', () => {
rule={ruleToMove}
/>
);
await user.click(byRole('button').get());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(byRole('button').get()));
expect(await byText(/success/i).find()).toBeInTheDocument();
@@ -87,7 +91,7 @@ describe('Moving a Grafana managed rule', () => {
uid: 'does-not-exist',
};
const { user } = render(
render(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={currentRuleGroupID}
@@ -95,7 +99,10 @@ describe('Moving a Grafana managed rule', () => {
rule={grafanaRulerRule}
/>
);
await user.click(byRole('button').get());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(byRole('button').get()));
expect(await byText(/error/i).find()).toBeInTheDocument();
});
@@ -130,7 +137,7 @@ describe('Moving a Data source managed rule', () => {
draft.grafana_alert.title = 'updated rule title';
});
const { user } = render(
render(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
@@ -138,7 +145,10 @@ describe('Moving a Data source managed rule', () => {
rule={newRule}
/>
);
await user.click(byRole('button').get());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(byRole('button').get()));
expect(await byText(/success/i).find()).toBeInTheDocument();
@@ -167,7 +177,7 @@ describe('Moving a Data source managed rule', () => {
const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
const { user } = render(
render(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
@@ -175,7 +185,10 @@ describe('Moving a Data source managed rule', () => {
rule={ruleToMove}
/>
);
await user.click(byRole('button').get());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(byRole('button').get()));
expect(await byText(/success/i).find()).toBeInTheDocument();
@@ -206,7 +219,7 @@ describe('Moving a Data source managed rule', () => {
const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
const { user } = render(
render(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
@@ -214,7 +227,10 @@ describe('Moving a Data source managed rule', () => {
rule={ruleToMove}
/>
);
await user.click(byRole('button').get());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(byRole('button').get()));
expect(await byText(/success/i).find()).toBeInTheDocument();
@@ -239,7 +255,7 @@ describe('Moving a Data source managed rule', () => {
draft.grafana_alert.title = 'updated rule title';
});
const { user } = render(
render(
<MoveRuleTestComponent
currentRuleGroupIdentifier={curentRuleGroupID}
targetRuleGroupIdentifier={curentRuleGroupID}
@@ -247,7 +263,10 @@ describe('Moving a Data source managed rule', () => {
rule={newRule}
/>
);
await user.click(byRole('button').get());
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(byRole('button').get()));
expect(await byText(/error/i).find()).toBeInTheDocument();
});

View File

@@ -31,7 +31,6 @@ describe('pause rule', () => {
expect(byText(/uninitialized/i).get()).toBeInTheDocument();
await userEvent.click(byRole('button').get());
expect(await byText(/loading/i).find()).toBeInTheDocument();
expect(await byText(/success/i).find()).toBeInTheDocument();
expect(await byText(/result/i).find()).toBeInTheDocument();
@@ -68,7 +67,6 @@ describe('pause rule', () => {
expect(await byText(/uninitialized/i).find()).toBeInTheDocument();
await userEvent.click(byRole('button').get());
expect(await byText(/loading/i).find()).toBeInTheDocument();
expect(byText(/success/i).query()).not.toBeInTheDocument();
expect(await byText(/error:(.+)oops/i).find()).toBeInTheDocument();
});

View File

@@ -84,8 +84,8 @@ export function useAsync<Result, Args extends unknown[] = unknown[]>(
error: undefined,
result: initialValue,
});
const promiseRef = useRef<Promise<Result>>();
const argsRef = useRef<Args>();
const promiseRef = useRef<Promise<Result>>(undefined);
const argsRef = useRef<Args>(undefined);
const methods = useSyncedRef({
execute(...params: Args) {

View File

@@ -155,8 +155,8 @@ export function useRuleGroupConsistencyCheck() {
const { isGroupInSync } = useRuleGroupIsInSync();
const [groupConsistent, setGroupConsistent] = useState<boolean | undefined>();
const apiCheckInterval = useRef<ReturnType<typeof setTimeout> | undefined>();
const timeoutInterval = useRef<ReturnType<typeof setTimeout> | undefined>();
const apiCheckInterval = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const timeoutInterval = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
return () => {
@@ -245,8 +245,8 @@ export function useRuleGroupConsistencyCheck() {
export function usePrometheusConsistencyCheck() {
const { matchingPromRuleExists } = useMatchingPromRuleExists();
const removalConsistencyInterval = useRef<number | undefined>();
const creationConsistencyInterval = useRef<number | undefined>();
const removalConsistencyInterval = useRef<number | undefined>(undefined);
const creationConsistencyInterval = useRef<number | undefined>(undefined);
useEffect(() => {
return () => {

View File

@@ -1,3 +1,4 @@
import { act, fireEvent } from '@testing-library/react';
import { render, screen, waitFor, within } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
@@ -58,7 +59,10 @@ describe('RuleList - GroupedView', () => {
});
it('should paginate through groups', async () => {
const { user } = render(<GroupedView />);
// TODO investigate why we need act
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act
await act(() => render(<GroupedView />));
const mimirSection = await ui.dsSection(/Mimir/).find();
@@ -73,7 +77,10 @@ describe('RuleList - GroupedView', () => {
expect(firstPageGroups[39]).toHaveTextContent('test-group-40');
const loadMoreButton = await within(mimirSection).findByRole('button', { name: /Show more/i });
await user.click(loadMoreButton);
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(() => fireEvent.click(loadMoreButton));
await waitFor(() => expect(loadMoreButton).toBeEnabled());
@@ -86,7 +93,10 @@ describe('RuleList - GroupedView', () => {
});
it('should disable next button when there is no more data', async () => {
const { user } = render(<GroupedView />);
// TODO investigate why we need act
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act
await act(() => render(<GroupedView />));
const prometheusSection = await ui.dsSection(/Prometheus/).find();
const promNamespace = await ui.namespace(/test-prometheus-namespace/).find(prometheusSection);
@@ -96,17 +106,27 @@ describe('RuleList - GroupedView', () => {
await ui.group('test-group-40').find(promNamespace);
// fetch page 2
await user.click(await loadMoreButton.find(prometheusSection));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(async () => fireEvent.click(await loadMoreButton.find(prometheusSection)));
// await user.click(await loadMoreButton.find(prometheusSection));
// we should now have all groups 1-80
await ui.group('test-group-80').find(promNamespace);
// fetch page 3
await user.click(await loadMoreButton.find(prometheusSection));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(async () => fireEvent.click(await loadMoreButton.find(prometheusSection)));
// we should now have all groups 1-120
await ui.group('test-group-120').find(promNamespace);
// fetch page 4
await user.click(await loadMoreButton.find(prometheusSection));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
await act(async () => fireEvent.click(await loadMoreButton.find(prometheusSection)));
// we should now have all groups 1-130
await ui.group('test-group-130').find(promNamespace);

View File

@@ -7,6 +7,9 @@ type Props = {
function LoadMoreHelper({ handleLoad }: Props) {
const intersectionRef = useRef<HTMLDivElement>(null);
// TODO remove when react-use is fixed
// see https://github.com/streamich/react-use/issues/2612
// @ts-expect-error
const intersection = useIntersection(intersectionRef, {
root: null,
threshold: 1,

View File

@@ -387,7 +387,7 @@ interface ListModeProps {
hasSearches: boolean;
canSave: boolean;
activeAction: ActiveAction;
saveButtonRef: React.RefObject<HTMLButtonElement>;
saveButtonRef: React.RefObject<HTMLButtonElement | null>;
isLoading: boolean;
onStartSave: () => void;
/** Callback to complete save. Throws ValidationError on validation failure. */

View File

@@ -67,7 +67,7 @@ describe('StandardAnnotationQueryEditor', () => {
expect.objectContaining({
query: expect.objectContaining({ queryType: 'defaultAnnotationsQuery', refId: 'initialAnnotationRef' }),
}),
expect.anything()
undefined
);
});
@@ -85,7 +85,7 @@ describe('StandardAnnotationQueryEditor', () => {
expect.objectContaining({
query: expect.objectContaining({ refId: 'initialAnnotationRef' }),
}),
expect.anything()
undefined
);
});
@@ -204,7 +204,7 @@ describe('StandardAnnotationQueryEditor', () => {
refId: 'A',
}),
}),
expect.anything()
undefined
);
});
@@ -242,7 +242,7 @@ describe('StandardAnnotationQueryEditor', () => {
legendFormat: '{{method}} {{endpoint}}',
}),
}),
expect.anything()
undefined
);
});
@@ -284,7 +284,7 @@ describe('StandardAnnotationQueryEditor', () => {
refId: 'AnnoTarget',
}),
}),
expect.anything()
undefined
);
});
@@ -320,7 +320,7 @@ describe('StandardAnnotationQueryEditor', () => {
expr: 'up',
}),
}),
expect.anything()
undefined
);
});

View File

@@ -141,7 +141,7 @@ function useScopesRow(onApply: () => void) {
function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) {
const { selectScope, searchAllNodes, getScopeNodes } = useScopeServicesState();
const [actions, setActions] = useState<CommandPaletteAction[] | undefined>(undefined);
const searchQueryRef = useRef<string>();
const searchQueryRef = useRef<string>(undefined);
useEffect(() => {
if ((!parentId || parentId === 'scopes') && searchQuery && config.featureToggles.scopeSearchAllLevels) {

View File

@@ -1,4 +1,4 @@
import { render, waitFor, screen, within, Matcher, getByRole } from '@testing-library/react';
import { render, waitFor, screen, within, Matcher, getByRole, act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { merge, uniqueId } from 'lodash';
import { openMenu } from 'react-select-event';
@@ -300,7 +300,9 @@ describe('CorrelationsPage', () => {
await userEvent.click(screen.getByText('Regular expression'));
await userEvent.type(screen.getByLabelText(/expression/i), 'test expression');
await userEvent.click(await screen.findByRole('button', { name: /add$/i }));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
await act(() => fireEvent.click(screen.getByRole('button', { name: /add$/i })));
await waitFor(() => {
expect(mocks.reportInteraction).toHaveBeenCalledWith('grafana_correlations_added');
@@ -451,7 +453,9 @@ describe('CorrelationsPage', () => {
await userEvent.clear(screen.getByRole('textbox', { name: /results field/i }));
await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line');
await userEvent.click(screen.getByRole('button', { name: /add$/i }));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
await act(() => fireEvent.click(screen.getByRole('button', { name: /add$/i })));
await waitFor(() => {
expect(mocks.reportInteraction).toHaveBeenCalledWith('grafana_correlations_added');
@@ -518,7 +522,9 @@ describe('CorrelationsPage', () => {
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
await act(() => fireEvent.click(screen.getByRole('button', { name: /save$/i })));
expect(await screen.findByRole('cell', { name: /edited label$/i }, { timeout: 5000 })).toBeInTheDocument();
@@ -536,7 +542,9 @@ describe('CorrelationsPage', () => {
const rowExpanderButton = within(tableRows[0]).getByRole('button', { name: /toggle row expanded/i });
await userEvent.click(rowExpanderButton);
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
await act(() => fireEvent.click(screen.getByRole('button', { name: /next$/i })));
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
// select Logfmt, be sure expression field is disabled
@@ -575,7 +583,9 @@ describe('CorrelationsPage', () => {
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
expect(screen.getByText('Please define an expression')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/expression/i), 'test expression');
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
await act(() => fireEvent.click(screen.getByRole('button', { name: /save$/i })));
await waitFor(() => {
expect(mocks.reportInteraction).toHaveBeenCalledWith('grafana_correlations_edited');
});
@@ -726,7 +736,9 @@ describe('CorrelationsPage', () => {
expect(descriptionInput).toBeInTheDocument();
expect(descriptionInput).toHaveAttribute('readonly');
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
await act(() => fireEvent.click(screen.getByRole('button', { name: /next$/i })));
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
// expect the transformation to exist but be read only

View File

@@ -58,7 +58,7 @@ jest.mock('app/features/plugins/extensions/getPluginExtensions', () => ({
createPluginExtensionsGetter: () => getPluginExtensionsMock,
}));
function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps> } = {}) {
async function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps> } = {}) {
const context = getGrafanaContextMock();
const defaultRouteProps = getRouteComponentProps();
const props: Props = {
@@ -66,21 +66,29 @@ function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps
...routeProps,
};
const renderResult = render(
<TestProvider grafanaContext={context}>
<LocationServiceProvider service={locationService}>
<DashboardScenePage {...props} />
</LocationServiceProvider>
</TestProvider>
);
const rerender = (newProps: Props) => {
renderResult.rerender(
// react 19 changed how suspense rendering works
// RTL hasn't caught up yet
// see https://github.com/testing-library/react-testing-library/issues/1375
// TODO remove this hack when RTL is updated. probably `render` itself will become async
const renderResult = await act(async () =>
render(
<TestProvider grafanaContext={context}>
<LocationServiceProvider service={locationService}>
<DashboardScenePage {...newProps} />
<DashboardScenePage {...props} />
</LocationServiceProvider>
</TestProvider>
)
);
const rerender = async (newProps: Props) => {
await act(async () =>
renderResult.rerender(
<TestProvider grafanaContext={context}>
<LocationServiceProvider service={locationService}>
<DashboardScenePage {...newProps} />
</LocationServiceProvider>
</TestProvider>
)
);
};
@@ -152,6 +160,8 @@ describe('DashboardScenePage', () => {
beforeEach(() => {
locationService.push('/d/my-dash-uid');
getDashboardScenePageStateManager().clearDashboardCache();
getDashboardScenePageStateManager().clearSceneCache();
getDashboardScenePageStateManager().clearState();
loadDashboardMock.mockClear();
loadDashboardMock.mockResolvedValue({ dashboard: simpleDashboard, meta: { slug: '123' } });
// hacky way because mocking autosizer does not work
@@ -163,7 +173,7 @@ describe('DashboardScenePage', () => {
});
it('Can render dashboard', async () => {
setup();
await setup();
await waitForDashboardToRender();
@@ -175,7 +185,7 @@ describe('DashboardScenePage', () => {
});
it('routeReloadCounter should trigger reload', async () => {
const { rerender, props } = setup();
const { rerender, props } = await setup();
await waitForDashboardToRender();
@@ -190,13 +200,13 @@ describe('DashboardScenePage', () => {
props.location.state = { routeReloadCounter: 1 };
rerender(props);
await rerender(props);
expect(await screen.findByTitle('Updated title')).toBeInTheDocument();
});
it('Can inspect panel', async () => {
setup();
await setup();
await waitForDashboardToRender();
@@ -218,7 +228,7 @@ describe('DashboardScenePage', () => {
});
it('Can view panel in fullscreen', async () => {
setup();
await setup();
await waitForDashboardToRender();
@@ -238,7 +248,7 @@ describe('DashboardScenePage', () => {
interval: {} as SystemDateFormatsState['interval'],
useBrowserLocale: true,
});
setup();
await setup();
await waitForDashboardToRenderWithTimeRange({
from: '03/11/2025, 02:09:37 AM',
@@ -257,7 +267,7 @@ describe('DashboardScenePage', () => {
interval: {} as SystemDateFormatsState['interval'],
useBrowserLocale: true,
});
setup();
await setup();
await waitForDashboardToRenderWithTimeRange({
from: '11.03.2025, 02:09:37',
@@ -269,17 +279,17 @@ describe('DashboardScenePage', () => {
describe('empty state', () => {
it('Shows empty state when dashboard is empty', async () => {
loadDashboardMock.mockResolvedValue({ dashboard: { uid: 'my-dash-uid', panels: [] }, meta: {} });
setup();
await setup();
expect(await screen.findByText('Start your new dashboard by adding a visualization')).toBeInTheDocument();
});
it('shows and hides empty state when panels are added and removed', async () => {
setup();
await setup();
await waitForDashboardToRender();
expect(await screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
expect(screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
// Hacking a bit, accessing private cache property to get access to the underlying DashboardScene object
const dashboardScenesCache = getDashboardScenePageStateManager().getCache();
@@ -289,7 +299,7 @@ describe('DashboardScenePage', () => {
act(() => {
dashboard.removePanel(panels[0]);
});
expect(await screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
expect(screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
act(() => {
dashboard.removePanel(panels[1]);
@@ -301,14 +311,14 @@ describe('DashboardScenePage', () => {
});
expect(await screen.findByTitle('Panel Added')).toBeInTheDocument();
expect(await screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
expect(screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
});
});
describe('home page', () => {
it('should render the dashboard when the route is home', async () => {
(useParams as jest.Mock).mockReturnValue({});
setup({
await setup({
routeProps: {
route: {
...getRouteComponentProps().route,
@@ -331,7 +341,7 @@ describe('DashboardScenePage', () => {
loadDashboardMock.mockClear();
loadDashboardMock.mockResolvedValue({ dashboard: { uid: 'my-dash-uid', panels: [] }, meta: {} });
setup();
await setup();
await waitFor(() => expect(screen.queryByText('Refresh')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText('Last 6 hours')).toBeInTheDocument());
@@ -363,7 +373,7 @@ describe('DashboardScenePage', () => {
isHandled: true,
});
setup();
await setup();
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
});
@@ -387,7 +397,7 @@ describe('DashboardScenePage', () => {
isHandled: true,
});
setup();
await setup();
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Internal server error');
@@ -396,7 +406,7 @@ describe('DashboardScenePage', () => {
it('should render error alert for runtime errors', async () => {
setupLoadDashboardRuntimeErrorMock();
setup();
await setup();
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Runtime error');
@@ -411,7 +421,7 @@ describe('DashboardScenePage', () => {
const manager = getDashboardScenePageStateManager();
manager.setActiveManager('v2');
const { unmount } = setup();
const { unmount } = await setup();
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
unmount();

View File

@@ -1,4 +1,4 @@
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import { act, screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { of } from 'rxjs';
import { render } from 'test/test-utils';
@@ -26,7 +26,7 @@ jest.mock('@grafana/runtime', () => ({
},
}));
function setup(token = 'an-access-token') {
async function setup(token = 'an-access-token') {
const pubdashProps: PublicDashboardSceneProps = {
...getRouteComponentProps({
route: {
@@ -37,11 +37,19 @@ function setup(token = 'an-access-token') {
}),
};
return render(
<Routes>
<Route path="/public-dashboards/:accessToken" element={<PublicDashboardScenePage {...pubdashProps} />} />
</Routes>,
{ historyOptions: { initialEntries: [`/public-dashboards/${token}`] } }
// TODO investigate why act is needed here
// see https://github.com/testing-library/react-testing-library/issues/1375
return await act(() =>
render(
<Routes>
<Route
path="/public-dashboards/:accessToken"
element={<PublicDashboardScenePage {...pubdashProps} />}
key={token}
/>
</Routes>,
{ historyOptions: { initialEntries: [`/public-dashboards/${token}`] } }
)
);
}
@@ -115,7 +123,6 @@ const publicDashboardSceneSelector = e2eSelectors.pages.PublicDashboardScene;
describe('PublicDashboardScenePage', () => {
beforeEach(() => {
config.publicDashboardAccessToken = 'an-access-token';
getDashboardScenePageStateManager().clearDashboardCache();
setupLoadDashboardMock({ dashboard: simpleDashboard, meta: {} });
@@ -125,7 +132,9 @@ describe('PublicDashboardScenePage', () => {
});
it('can render public dashboard', async () => {
setup();
const accessToken = 'an-access-token';
config.publicDashboardAccessToken = accessToken;
await setup(accessToken);
await waitForDashboardGridToRender();
@@ -139,7 +148,9 @@ describe('PublicDashboardScenePage', () => {
});
it('cannot see menu panel', async () => {
setup();
const accessToken = 'cannot-see-menu-panel';
config.publicDashboardAccessToken = accessToken;
await setup(accessToken);
await waitForDashboardGridToRender();
@@ -148,7 +159,9 @@ describe('PublicDashboardScenePage', () => {
});
it('shows time controls when it is not hidden', async () => {
setup();
const accessToken = 'shows-time-controls';
config.publicDashboardAccessToken = accessToken;
await setup(accessToken);
await waitForDashboardGridToRender();
@@ -158,7 +171,9 @@ describe('PublicDashboardScenePage', () => {
});
it('does not render paused or deleted screen', async () => {
setup();
const accessToken = 'does-not-render-paused-or-deleted-screen';
config.publicDashboardAccessToken = accessToken;
await setup(accessToken);
await waitForDashboardGridToRender();
@@ -172,7 +187,7 @@ describe('PublicDashboardScenePage', () => {
dashboard: { ...simpleDashboard, timepicker: { hidden: true } },
meta: {},
});
setup(accessToken);
await setup(accessToken);
await waitForDashboardGridToRender();
@@ -207,9 +222,7 @@ describe('given unavailable public dashboard', () => {
},
});
setup(accessToken);
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));
await setup(accessToken);
expect(screen.queryByTestId(publicDashboardSceneSelector.page)).not.toBeInTheDocument();
expect(screen.getByTestId(publicDashboardSelector.NotAvailable.title)).toBeInTheDocument();
@@ -239,9 +252,7 @@ describe('given unavailable public dashboard', () => {
},
});
setup(accessToken);
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));
await setup(accessToken);
expect(screen.queryByTestId(publicDashboardSelector.page)).not.toBeInTheDocument();
expect(screen.queryByTestId(publicDashboardSelector.NotAvailable.pausedDescription)).not.toBeInTheDocument();

View File

@@ -49,7 +49,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
const [contentSent, setContentSent] = useState<{ title?: string; folderUid?: string }>({});
const validationTimeoutRef = useRef<NodeJS.Timeout>();
const validationTimeoutRef = useRef<NodeJS.Timeout>(null);
// Validate title on form mount to catch invalid default values
useEffect(() => {
@@ -59,14 +59,18 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
// Cleanup timeout on unmount
useEffect(() => {
return () => {
clearTimeout(validationTimeoutRef.current);
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current);
}
};
}, []);
const handleTitleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setValue('title', e.target.value, { shouldDirty: true });
clearTimeout(validationTimeoutRef.current);
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current);
}
validationTimeoutRef.current = setTimeout(() => {
trigger('title');
}, 400);
@@ -75,7 +79,9 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
);
const onSave = async (overwrite: boolean) => {
clearTimeout(validationTimeoutRef.current);
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current);
}
const isTitleValid = await trigger('title');

View File

@@ -15,7 +15,7 @@ import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
interface PanelWrapperProps {
panel: VizPanel;
isLazy: boolean;
containerRef?: RefObject<HTMLDivElement>;
containerRef?: RefObject<HTMLDivElement | null>;
}
function PanelWrapper({ panel, isLazy, containerRef }: PanelWrapperProps) {

View File

@@ -8,7 +8,7 @@ import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
* Will scroll element into view. If element is not connected yet, it will try to expand rows
* and switch tabs to make it visible.
*/
export function scrollCanvasElementIntoView(sceneObject: SceneObject, ref: React.RefObject<HTMLElement>) {
export function scrollCanvasElementIntoView(sceneObject: SceneObject, ref: React.RefObject<HTMLElement | null>) {
if (ref.current?.isConnected) {
scrollIntoView(ref.current);
return;

View File

@@ -33,7 +33,7 @@ global.ResizeObserver = jest.fn().mockImplementation((callback) => {
});
// Helper function to assign a mock div to a ref
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement>, mockDiv: HTMLDivElement) {
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement | null>, mockDiv: HTMLDivElement) {
// Use type assertion to bypass readonly restriction in tests
(ref as { current: HTMLDivElement | null }).current = mockDiv;
}

View File

@@ -8,7 +8,7 @@ import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg';
import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg';
interface SoloPanelPageLogoProps {
containerRef: React.RefObject<HTMLDivElement>;
containerRef: React.RefObject<HTMLDivElement | null>;
isHovered: boolean;
hideLogo?: UrlQueryValue;
}

View File

@@ -1,4 +1,4 @@
import { screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
@@ -154,7 +154,9 @@ describe('VersionSettings', () => {
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(VERSIONS_FETCH_LIMIT);
const showMoreButton = screen.getByRole('button', { name: /show more versions/i });
await user.click(showMoreButton);
// TODO investigate why we need act/fireEvent
// see https://github.com/testing-library/react-testing-library/issues/1375
act(() => fireEvent.click(showMoreButton));
expect(historySrv.getHistoryList).toBeCalledTimes(2);
expect(screen.getByText(/Fetching more entries/i)).toBeInTheDocument();

View File

@@ -21,7 +21,7 @@ export const usePanelLatestData = (
options: GetDataOptions,
checkSchema?: boolean
): UsePanelLatestData => {
const querySubscription = useRef<Unsubscribable>();
const querySubscription = useRef<Unsubscribable>(null);
const [latestData, setLatestData] = useState<PanelData>();
useEffect(() => {

View File

@@ -61,7 +61,7 @@ interface State {
class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
subscription?: Unsubscribable;
ref: RefObject<HTMLDivElement>;
ref: RefObject<HTMLDivElement | null>;
constructor(props: TransformationsEditorProps) {
super(props);

View File

@@ -1,4 +1,4 @@
import { screen, waitFor } from '@testing-library/react';
import { act, screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom-v5-compat';
import { render } from 'test/test-utils';
@@ -62,7 +62,9 @@ describe('PublicDashboardPageProxy', () => {
describe('when scene feature enabled', () => {
it('should render PublicDashboardScenePage if publicDashboardsScene is enabled', async () => {
config.featureToggles.publicDashboardsScene = true;
setup({});
// TODO investigate why we need act
// see https://github.com/testing-library/react-testing-library/issues/1375
await act(() => setup({}));
await waitFor(() => {
expect(screen.queryByTestId(PublicDashboardScene.page)).toBeInTheDocument();

View File

@@ -78,6 +78,7 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) =>
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
libraryItemId: dashboard.uid,
creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD,
contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD,
});
const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`;

View File

@@ -0,0 +1,125 @@
import { screen, waitFor } from '@testing-library/react';
import React from 'react';
import { render } from 'test/test-utils';
import { CommunityDashboardSection } from './CommunityDashboardSection';
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
import { GnetDashboard } from './types';
import { onUseCommunityDashboard } from './utils/communityDashboardHelpers';
jest.mock('./api/dashboardLibraryApi', () => ({
fetchCommunityDashboards: jest.fn(),
}));
jest.mock('./utils/communityDashboardHelpers', () => ({
...jest.requireActual('./utils/communityDashboardHelpers'),
onUseCommunityDashboard: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn((uid: string) => ({
uid,
name: `DataSource ${uid}`,
type: 'test',
})),
}),
}));
const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction<typeof fetchCommunityDashboards>;
const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction<typeof onUseCommunityDashboard>;
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 1,
name: 'Test Dashboard',
description: 'Test Description',
downloads: 2000,
datasource: 'Prometheus',
slug: 'test-dashboard',
...overrides,
});
const setup = async (
props: Partial<React.ComponentProps<typeof CommunityDashboardSection>> = {},
successScenario = true
) => {
const renderResult = render(
<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType="test" {...props} />,
{
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-datasource-uid'],
},
}
);
if (successScenario) {
await waitFor(() => {
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
});
}
return renderResult;
};
describe('CommunityDashboardSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render', async () => {
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [
createMockGnetDashboard(),
createMockGnetDashboard({ id: 2, name: 'Test Dashboard 2' }),
createMockGnetDashboard({ id: 3, name: 'Test Dashboard 3' }),
],
});
await setup();
await waitFor(() => {
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
expect(screen.getByText('Test Dashboard 2')).toBeInTheDocument();
expect(screen.getByText('Test Dashboard 3')).toBeInTheDocument();
});
});
it('should show error when fetching a specific community dashboard after clicking use dashboard button fails', async () => {
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
mockOnUseCommunityDashboard.mockRejectedValue(new Error('Failed to use community dashboard'));
const { user } = await setup();
await waitFor(() => {
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
});
const useDashboardButton = screen.getByRole('button', { name: 'Use dashboard' });
await user.click(useDashboardButton);
await waitFor(() => {
expect(screen.getByText('Error loading community dashboard')).toBeInTheDocument();
});
});
it('should show error when fetching community dashboards list fails', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockFetchCommunityDashboards.mockRejectedValue(new Error('Failed to fetch community dashboards'));
await setup(undefined, false);
await waitFor(() => {
expect(screen.getByText('Error loading community dashboards')).toBeInTheDocument();
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboards', expect.any(Error));
consoleErrorSpy.mockRestore();
});
});

View File

@@ -1,12 +1,12 @@
import { css } from '@emotion/css';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useAsync, useDebounce } from 'react-use';
import { useAsyncFn, useAsyncRetry, useDebounce } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterInput } from '@grafana/ui';
import { Button, useStyles2, Stack, Grid, EmptyState, Alert, FilterInput, Box } from '@grafana/ui';
import { DashboardCard } from './DashboardCard';
import { MappingContext } from './SuggestedDashboardsModal';
@@ -24,6 +24,8 @@ import {
getLogoUrl,
buildDashboardDetails,
onUseCommunityDashboard,
COMMUNITY_PAGE_SIZE_QUERY,
COMMUNITY_RESULT_SIZE,
} from './utils/communityDashboardHelpers';
interface Props {
@@ -31,8 +33,6 @@ interface Props {
datasourceType?: string;
}
// Constants for community dashboard pagination and API params
const COMMUNITY_PAGE_SIZE = 9;
const SEARCH_DEBOUNCE_MS = 500;
const DEFAULT_SORT_ORDER = 'downloads';
const DEFAULT_SORT_DIRECTION = 'desc';
@@ -42,7 +42,6 @@ const INCLUDE_SCREENSHOTS = true;
export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Props) => {
const [searchParams] = useSearchParams();
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const hasTrackedLoaded = useRef(false);
@@ -55,18 +54,12 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
[searchQuery]
);
// Reset to page 1 when debounced search query changes
useEffect(() => {
if (debouncedSearchQuery) {
setCurrentPage(1);
}
}, [debouncedSearchQuery]);
const {
value: response,
loading,
error,
} = useAsync(async () => {
retry,
} = useAsyncRetry(async () => {
if (!datasourceUid) {
return null;
}
@@ -80,8 +73,8 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
const apiResponse = await fetchCommunityDashboards({
orderBy: DEFAULT_SORT_ORDER,
direction: DEFAULT_SORT_DIRECTION,
page: currentPage,
pageSize: COMMUNITY_PAGE_SIZE,
page: 1,
pageSize: COMMUNITY_PAGE_SIZE_QUERY,
includeLogo: INCLUDE_LOGO,
includeScreenshots: INCLUDE_SCREENSHOTS,
dataSourceSlugIn: ds.type,
@@ -100,15 +93,14 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
}
return {
dashboards: apiResponse.items,
pages: apiResponse.pages,
dashboards: apiResponse.items.slice(0, COMMUNITY_RESULT_SIZE),
datasourceType: ds.type,
};
} catch (err) {
console.error('Error loading community dashboards', err);
throw err;
}
}, [datasourceUid, currentPage, debouncedSearchQuery]);
}, [datasourceUid, debouncedSearchQuery]);
// Track analytics only once on first successful load
useEffect(() => {
@@ -128,37 +120,49 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
// Determine what to show in results area
const dashboards = Array.isArray(response?.dashboards) ? response.dashboards : [];
const totalPages = response?.pages || 1;
const showEmptyState = !loading && (!response?.dashboards || response.dashboards.length === 0);
const showError = !loading && error;
const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => {
if (!response) {
return;
}
const [{ error: isPreviewDashboardError }, onPreviewCommunityDashboard] = useAsyncFn(
async (dashboard: GnetDashboard) => {
if (!response) {
return;
}
// Track item click
DashboardLibraryInteractions.itemClicked({
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes: [response.datasourceType],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE,
});
// Track item click
DashboardLibraryInteractions.itemClicked({
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes: [response.datasourceType],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE,
});
onUseCommunityDashboard({
dashboard,
datasourceUid: datasourceUid || '',
datasourceType: response.datasourceType,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
onShowMapping,
});
};
await onUseCommunityDashboard({
dashboard,
datasourceUid: datasourceUid || '',
datasourceType: response.datasourceType,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
onShowMapping,
});
},
[response, datasourceUid, debouncedSearchQuery, onShowMapping]
);
return (
<Stack direction="column" gap={2} height="100%">
{isPreviewDashboardError && (
<div>
<Alert
title={t('dashboard-library.community-error-title', 'Error loading community dashboard')}
severity="error"
>
<Trans i18nKey="dashboard-library.community-error-description">Failed to load community dashboard.</Trans>
</Alert>
</div>
)}
<FilterInput
placeholder={
datasourceType
@@ -183,7 +187,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
lg: 3,
}}
>
{Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => (
{Array.from({ length: COMMUNITY_RESULT_SIZE }).map((_, i) => (
<DashboardCard.Skeleton key={`skeleton-${i}`} />
))}
</Grid>
@@ -197,7 +201,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
Failed to load community dashboards. Please try again.
</Trans>
</Alert>
<Button variant="secondary" onClick={() => setCurrentPage(1)}>
<Button variant="secondary" onClick={retry}>
<Trans i18nKey="dashboard-library.retry">Retry</Trans>
</Button>
</Stack>
@@ -233,42 +237,47 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
)}
</EmptyState>
) : (
<Grid
gap={4}
columns={{
xs: 1,
sm: dashboards.length >= 2 ? 2 : 1,
lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1,
}}
>
{dashboards.map((dashboard) => {
const thumbnailUrl = getThumbnailUrl(dashboard);
const logoUrl = getLogoUrl(dashboard);
const imageUrl = thumbnailUrl || logoUrl;
const isLogo = !thumbnailUrl;
const details = buildDashboardDetails(dashboard);
<Stack direction="column" gap={2}>
<Grid
gap={4}
columns={{
xs: 1,
sm: dashboards.length >= 2 ? 2 : 1,
lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1,
}}
>
{dashboards.map((dashboard) => {
const thumbnailUrl = getThumbnailUrl(dashboard);
const logoUrl = getLogoUrl(dashboard);
const imageUrl = thumbnailUrl || logoUrl;
const isLogo = !thumbnailUrl;
const details = buildDashboardDetails(dashboard);
return (
<DashboardCard
key={dashboard.id}
title={dashboard.name}
imageUrl={imageUrl}
dashboard={dashboard}
onClick={() => onPreviewCommunityDashboard(dashboard)}
isLogo={isLogo}
details={details}
kind="suggested_dashboard"
/>
);
})}
</Grid>
return (
<DashboardCard
key={dashboard.id}
title={dashboard.name}
imageUrl={imageUrl}
dashboard={dashboard}
onClick={() => onPreviewCommunityDashboard(dashboard)}
isLogo={isLogo}
details={details}
kind="suggested_dashboard"
/>
);
})}
</Grid>
<Box display="flex" justifyContent="end" gap={2} paddingRight={1.5}>
<Button
variant="secondary"
onClick={() => window.open('https://grafana.com/grafana/dashboards/', '_blank')}
>
<Trans i18nKey="dashboard-library.browse-grafana-com">Browse Grafana.com</Trans>
</Button>
</Box>
</Stack>
)}
</div>
{totalPages > 1 && (
<div className={styles.paginationWrapper}>
<Pagination currentPage={currentPage} numberOfPages={totalPages} onNavigate={setCurrentPage} />
</div>
)}
</Stack>
);
};
@@ -277,18 +286,9 @@ function getStyles(theme: GrafanaTheme2) {
return {
resultsContainer: css({
width: '100%',
position: 'relative',
flex: 1,
overflow: 'auto',
}),
paginationWrapper: css({
position: 'sticky',
bottom: 0,
backgroundColor: theme.colors.background.primary,
padding: theme.spacing(2),
display: 'flex',
justifyContent: 'flex-end',
zIndex: 2,
paddingBottom: theme.spacing(2),
}),
};
}

View File

@@ -1,41 +1,8 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { PluginDashboard } from 'app/types/plugins';
import { DashboardCard } from './DashboardCard';
import { GnetDashboard } from './types';
// Helper functions for creating mock objects
const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): PluginDashboard => ({
dashboardId: 1,
description: 'Test description',
imported: false,
importedRevision: 0,
importedUri: '',
importedUrl: '',
path: '',
pluginId: 'test-plugin',
removed: false,
revision: 1,
slug: 'test-dashboard',
title: 'Test Dashboard',
uid: 'test-uid',
...overrides,
});
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 123,
name: 'Test Dashboard',
description: 'Test description',
datasource: 'Prometheus',
orgName: 'Test Org',
userName: 'testuser',
publishedAt: '',
updatedAt: '',
downloads: 0,
...overrides,
});
import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils';
const createMockDetails = (overrides = {}) => ({
id: '123',

View File

@@ -0,0 +1,273 @@
import { screen, waitFor, within } from '@testing-library/react';
import { render } from 'test/test-utils';
import { locationService } from '@grafana/runtime';
import { DashboardLibrarySection } from './DashboardLibrarySection';
import { fetchProvisionedDashboards } from './api/dashboardLibraryApi';
import { DashboardLibraryInteractions } from './interactions';
import { createMockPluginDashboard } from './utils/test-utils';
jest.mock('./api/dashboardLibraryApi', () => ({
fetchProvisionedDashboards: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn((uid?: string) => {
if (uid) {
return {
uid,
name: `DataSource ${uid}`,
type: 'test-datasource',
};
}
return null;
}),
}),
locationService: {
push: jest.fn(),
getHistory: jest.fn(() => ({
listen: jest.fn(() => jest.fn()),
})),
},
}));
jest.mock('./interactions', () => ({
...jest.requireActual('./interactions'),
DashboardLibraryInteractions: {
loaded: jest.fn(),
itemClicked: jest.fn(),
},
}));
jest.mock('./DashboardCard', () => {
const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => (
<div data-testid={`dashboard-card-${title}`} onClick={onClick}>
{title}
</div>
);
const DashboardCardSkeleton = () => <div data-testid="dashboard-card-skeleton">Skeleton</div>;
return {
DashboardCard: Object.assign(DashboardCardComponent, {
Skeleton: DashboardCardSkeleton,
}),
};
});
const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction<
typeof fetchProvisionedDashboards
>;
const mockLocationServicePush = locationService.push as jest.MockedFunction<typeof locationService.push>;
const mockDashboardLibraryInteractionsLoaded = DashboardLibraryInteractions.loaded as jest.MockedFunction<
typeof DashboardLibraryInteractions.loaded
>;
const mockDashboardLibraryInteractionsItemClicked = DashboardLibraryInteractions.itemClicked as jest.MockedFunction<
typeof DashboardLibraryInteractions.itemClicked
>;
describe('DashboardLibrarySection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render dashboards when they are available', async () => {
const dashboards = [
createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }),
createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }),
];
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
},
});
await waitFor(() => {
expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-card-Dashboard 2')).toBeInTheDocument();
});
});
it('should show empty state when there are no dashboards', async () => {
mockFetchProvisionedDashboards.mockResolvedValue([]);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
},
});
await waitFor(() => {
expect(screen.getByText('No test-datasource provisioned dashboards found')).toBeInTheDocument();
expect(
screen.getByText(
'Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com.'
)
).toBeInTheDocument();
const browseButton = screen.getByRole('button', { name: 'Browse plugins' });
expect(browseButton).toBeInTheDocument();
});
});
it('should show empty state without datasource type when datasourceUid is not provided', async () => {
mockFetchProvisionedDashboards.mockResolvedValue([]);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test'],
},
});
await waitFor(() => {
expect(screen.getByText('No provisioned dashboards found')).toBeInTheDocument();
});
});
it('should render pagination when there are more than 9 dashboards', async () => {
const dashboards = Array.from({ length: 18 }, (_, i) =>
createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` })
);
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
},
});
await waitFor(() => {
const pagination = screen.getByRole('navigation');
expect(pagination).toBeInTheDocument();
expect(within(pagination).getByText('1')).toBeInTheDocument();
expect(within(pagination).getByText('2')).toBeInTheDocument();
});
});
it('should not render pagination when there are 9 or fewer dashboards', async () => {
const dashboards = Array.from({ length: 9 }, (_, i) =>
createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` })
);
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
},
});
await waitFor(() => {
expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument();
});
const pagination = screen.queryByRole('navigation');
expect(pagination).not.toBeInTheDocument();
});
it('should navigate to template route when clicking on a dashboard', async () => {
const dashboard = createMockPluginDashboard({
title: 'Test Dashboard',
uid: 'test-uid-123',
pluginId: 'test-plugin',
path: 'test/path.json',
});
mockFetchProvisionedDashboards.mockResolvedValue([dashboard]);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
},
});
await waitFor(() => {
expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument();
});
const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard');
dashboardCard.click();
await waitFor(() => {
expect(mockLocationServicePush).toHaveBeenCalled();
const callArgs = mockLocationServicePush.mock.calls[0][0];
expect(callArgs).toContain('/dashboard/template');
expect(callArgs).toContain('datasource=test-uid');
expect(callArgs).toContain('title=Test+Dashboard');
expect(callArgs).toContain('pluginId=test-plugin');
expect(callArgs).toContain('path=test%2Fpath.json');
expect(callArgs).toContain('libraryItemId=test-uid-123');
});
});
it('should track analytics when dashboards are loaded', async () => {
const dashboards = [
createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }),
createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }),
];
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
},
});
await waitFor(() => {
expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument();
});
await waitFor(() => {
expect(mockDashboardLibraryInteractionsLoaded).toHaveBeenCalledWith({
numberOfItems: 2,
contentKinds: ['datasource_dashboard'],
datasourceTypes: ['test-datasource'],
sourceEntryPoint: 'datasource_page',
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
});
});
});
it('should track analytics when a dashboard is clicked', async () => {
const dashboard = createMockPluginDashboard({
title: 'Test Dashboard',
uid: 'test-uid-123',
pluginId: 'test-plugin',
});
mockFetchProvisionedDashboards.mockResolvedValue([dashboard]);
render(<DashboardLibrarySection />, {
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
},
});
await waitFor(() => {
expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument();
});
const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard');
dashboardCard.click();
await waitFor(() => {
expect(mockDashboardLibraryInteractionsItemClicked).toHaveBeenCalledWith({
contentKind: 'datasource_dashboard',
datasourceTypes: ['test-plugin'],
libraryItemId: 'test-uid-123',
libraryItemTitle: 'Test Dashboard',
sourceEntryPoint: 'datasource_page',
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
discoveryMethod: 'browse',
});
});
});
});

View File

@@ -0,0 +1,186 @@
import { screen, waitFor } from '@testing-library/react';
import { render } from 'test/test-utils';
import { SuggestedDashboards } from './SuggestedDashboards';
import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi';
import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils';
jest.mock('./api/dashboardLibraryApi', () => ({
fetchProvisionedDashboards: jest.fn(),
fetchCommunityDashboards: jest.fn(),
}));
jest.mock('./utils/communityDashboardHelpers', () => ({
...jest.requireActual('./utils/communityDashboardHelpers'),
onUseCommunityDashboard: jest.fn(),
}));
jest.mock('./SuggestedDashboardsModal', () => ({
SuggestedDashboardsModal: () => <div data-testid="suggested-dashboards-modal">Modal</div>,
}));
jest.mock('./DashboardCard', () => {
const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => (
<div data-testid={`dashboard-card-${title}`} onClick={onClick}>
{title}
</div>
);
const DashboardCardSkeleton = () => <div data-testid="dashboard-card-skeleton">Skeleton</div>;
return {
DashboardCard: Object.assign(DashboardCardComponent, {
Skeleton: DashboardCardSkeleton,
}),
};
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn((uid?: string) => {
if (uid) {
return {
uid,
name: `DataSource ${uid}`,
type: 'test-datasource',
};
}
return null;
}),
}),
}));
jest.mock('./interactions', () => ({
...jest.requireActual('./interactions'),
DashboardLibraryInteractions: {
loaded: jest.fn(),
itemClicked: jest.fn(),
},
}));
const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction<
typeof fetchProvisionedDashboards
>;
const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction<typeof fetchCommunityDashboards>;
describe('SuggestedDashboards', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render when there are dashboards', async () => {
mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]);
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 1,
items: [createMockGnetDashboard()],
});
render(<SuggestedDashboards datasourceUid="test-uid" />);
await waitFor(() => {
expect(screen.getByTestId('suggested-dashboards')).toBeInTheDocument();
});
});
it('should not render when there are no dashboards', async () => {
mockFetchProvisionedDashboards.mockResolvedValue([]);
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 1,
items: [],
});
render(<SuggestedDashboards datasourceUid="test-uid" />);
await waitFor(() => {
expect(screen.queryByTestId('suggested-dashboards')).not.toBeInTheDocument();
});
});
it('should render provisioned dashboard cards', async () => {
const provisionedDashboard = createMockPluginDashboard({ title: 'Provisioned Dashboard 1' });
mockFetchProvisionedDashboards.mockResolvedValue([provisionedDashboard]);
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 1,
items: [],
});
render(<SuggestedDashboards datasourceUid="test-uid" />);
await waitFor(() => {
expect(screen.getByTestId('dashboard-card-Provisioned Dashboard 1')).toBeInTheDocument();
});
});
it('should render community dashboard cards', async () => {
const communityDashboard = createMockGnetDashboard({ name: 'Community Dashboard 1' });
mockFetchProvisionedDashboards.mockResolvedValue([]);
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 1,
items: [communityDashboard],
});
render(<SuggestedDashboards datasourceUid="test-uid" />);
await waitFor(() => {
expect(screen.getByTestId('dashboard-card-Community Dashboard 1')).toBeInTheDocument();
});
});
it('should show "View all" button when hasMoreDashboards is true', async () => {
mockFetchProvisionedDashboards.mockResolvedValue([
createMockPluginDashboard(),
createMockPluginDashboard({ title: 'Provisioned Dashboard 2' }),
]);
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 1,
items: [],
});
render(<SuggestedDashboards datasourceUid="test-uid" />);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'View all' })).toBeInTheDocument();
});
});
it('should not show "View all" button when hasMoreDashboards is false', async () => {
mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]);
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 1,
items: [createMockGnetDashboard()],
});
render(<SuggestedDashboards datasourceUid="test-uid" />);
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'View all' })).not.toBeInTheDocument();
});
});
it('should render title and subtitle with datasource type when datasourceUid is provided', async () => {
mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]);
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 1,
items: [],
});
render(<SuggestedDashboards datasourceUid="test-uid" />);
await waitFor(() => {
expect(
screen.getByText('Build a dashboard using suggested options for your test-datasource data source')
).toBeInTheDocument();
expect(
screen.getByText('Browse and select from data-source provided or community dashboards')
).toBeInTheDocument();
});
});
});

View File

@@ -1,12 +1,12 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { useAsync, useAsyncFn } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv, locationService } from '@grafana/runtime';
import { Button, useStyles2, Grid } from '@grafana/ui';
import { Button, useStyles2, Grid, Alert } from '@grafana/ui';
import { PluginDashboard } from 'app/types/plugins';
import { DashboardCard } from './DashboardCard';
@@ -26,6 +26,8 @@ import {
getLogoUrl,
buildDashboardDetails,
onUseCommunityDashboard,
COMMUNITY_PAGE_SIZE_QUERY,
COMMUNITY_RESULT_SIZE,
} from './utils/communityDashboardHelpers';
import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers';
@@ -43,7 +45,7 @@ type SuggestedDashboardsResult = {
};
// Constants for suggested dashboards API params
const SUGGESTED_COMMUNITY_PAGE_SIZE = 2;
const MAX_SUGGESTED_DASHBOARDS_PREVIEW = 2;
const DEFAULT_SORT_ORDER = 'downloads';
const DEFAULT_SORT_DIRECTION = 'desc';
const INCLUDE_SCREENSHOTS = true;
@@ -91,14 +93,14 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
orderBy: DEFAULT_SORT_ORDER,
direction: DEFAULT_SORT_DIRECTION,
page: 1,
pageSize: SUGGESTED_COMMUNITY_PAGE_SIZE,
pageSize: COMMUNITY_PAGE_SIZE_QUERY,
includeScreenshots: INCLUDE_SCREENSHOTS,
dataSourceSlugIn: ds.type,
includeLogo: INCLUDE_LOGO,
}),
]);
const community = communityResponse.items;
const community = communityResponse.items.slice(0, COMMUNITY_RESULT_SIZE);
// Mix: 1 provisioned + 2 community
const mixed: MixedDashboard[] = [];
@@ -130,7 +132,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
// Determine if there are more dashboards available beyond what we're showing
// Show "View all" if: more than 1 provisioned exists OR we got the full page size of community dashboards
const hasMoreDashboards = provisioned.length > 1 || community.length >= SUGGESTED_COMMUNITY_PAGE_SIZE;
const hasMoreDashboards = provisioned.length > 1 || community.length > MAX_SUGGESTED_DASHBOARDS_PREVIEW;
return { dashboards: mixed, hasMoreDashboards };
} catch (error) {
@@ -233,35 +235,38 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
locationService.push(`/dashboard/template?${params.toString()}`);
};
const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => {
if (!datasourceUid) {
return;
}
const [{ error: isPreviewCommunityDashboardError }, onPreviewCommunityDashboard] = useAsyncFn(
async (dashboard: GnetDashboard) => {
if (!datasourceUid) {
return;
}
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
if (!ds) {
return;
}
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
if (!ds) {
return;
}
// Track item click
DashboardLibraryInteractions.itemClicked({
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes: [ds.type],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
discoveryMethod: DISCOVERY_METHODS.BROWSE,
});
// Track item click
DashboardLibraryInteractions.itemClicked({
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes: [ds.type],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
discoveryMethod: DISCOVERY_METHODS.BROWSE,
});
onUseCommunityDashboard({
dashboard,
datasourceUid,
datasourceType: ds.type,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
onShowMapping: onShowMapping,
});
};
await onUseCommunityDashboard({
dashboard,
datasourceUid,
datasourceType: ds.type,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
onShowMapping: onShowMapping,
});
},
[datasourceUid, onShowMapping]
);
// Don't render if no dashboards or still loading
if (!loading && (!result || result.dashboards.length === 0)) {
@@ -297,7 +302,16 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
</Button>
)}
</div>
{isPreviewCommunityDashboardError && (
<div>
<Alert
title={t('dashboard-library.community-error-title', 'Error loading community dashboard')}
severity="error"
>
<Trans i18nKey="dashboard-library.community-error-description">Failed to load community dashboard.</Trans>
</Alert>
</div>
)}
<Grid
gap={4}
columns={{

View File

@@ -0,0 +1,101 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { SuggestedDashboardsModal } from './SuggestedDashboardsModal';
import { CONTENT_KINDS, EVENT_LOCATIONS } from './interactions';
jest.mock('./DashboardLibrarySection', () => ({
DashboardLibrarySection: () => <div data-testid="dashboard-library-section">Dashboard Library Section</div>,
}));
jest.mock('./CommunityDashboardSection', () => ({
CommunityDashboardSection: () => <div data-testid="community-dashboard-section">Community Dashboard Section</div>,
}));
jest.mock('./CommunityDashboardMappingForm', () => ({
CommunityDashboardMappingForm: () => (
<div data-testid="community-dashboard-mapping-form">Community Dashboard Mapping Form</div>
),
}));
describe('SuggestedDashboardsModal', () => {
const defaultProps = {
isOpen: true,
onDismiss: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render when isOpen is true', () => {
render(<SuggestedDashboardsModal {...defaultProps} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('should not render when isOpen is false', () => {
render(<SuggestedDashboardsModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should render both tabs: Data-source provided and Community', () => {
render(<SuggestedDashboardsModal {...defaultProps} />);
expect(screen.getByRole('tab', { name: 'Data-source provided' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Community' })).toBeInTheDocument();
});
it('should render tablist with both tabs', () => {
render(<SuggestedDashboardsModal {...defaultProps} />);
const tablist = screen.getByRole('tablist');
expect(tablist).toBeInTheDocument();
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(2);
expect(tabs[0]).toHaveTextContent('Data-source provided');
expect(tabs[1]).toHaveTextContent('Community');
});
it('should render DashboardLibrarySection when activeView is datasource', () => {
render(<SuggestedDashboardsModal {...defaultProps} defaultTab="datasource" />);
expect(screen.getByTestId('dashboard-library-section')).toBeInTheDocument();
expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument();
expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument();
});
it('should render CommunityDashboardSection when activeView is community', () => {
render(<SuggestedDashboardsModal {...defaultProps} defaultTab="community" />);
expect(screen.getByTestId('community-dashboard-section')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument();
expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument();
});
it('should render CommunityDashboardMappingForm when activeView is mapping', () => {
render(
<SuggestedDashboardsModal
{...defaultProps}
initialMappingContext={{
dashboardName: 'Test Dashboard',
dashboardJson: { title: 'Test Dashboard', panels: [], schemaVersion: 41 } as DashboardJson,
unmappedDsInputs: [],
constantInputs: [],
existingMappings: [],
onInterpolateAndNavigate: jest.fn(),
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
}}
/>
);
expect(screen.getByTestId('community-dashboard-mapping-form')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument();
expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument();
});
});

View File

@@ -3,6 +3,7 @@ import { DashboardJson } from 'app/features/manage-dashboards/types';
import { PluginDashboard } from 'app/types/plugins';
import { GnetDashboard } from '../types';
import { createMockGnetDashboard, createMockPluginDashboard } from '../utils/test-utils';
import {
fetchCommunityDashboard,
@@ -14,8 +15,16 @@ import {
jest.mock('@grafana/runtime', () => ({
getBackendSrv: jest.fn(),
reportInteraction: jest.fn(),
}));
jest.mock('../interactions', () => ({
...jest.requireActual('../interactions'),
DashboardLibraryInteractions: {
...jest.requireActual('../interactions').DashboardLibraryInteractions,
communityDashboardFiltered: jest.fn(),
},
}));
const mockGetBackendSrv = getBackendSrv as jest.MockedFunction<typeof getBackendSrv>;
// Helper to create mock BackendSrv
@@ -26,31 +35,9 @@ const createMockBackendSrv = (overrides: Partial<BackendSrv> = {}): BackendSrv =
}) as unknown as BackendSrv;
// Helper functions for creating mock objects
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 1,
name: 'Test Dashboard',
description: 'Test Description',
downloads: 100,
datasource: 'Prometheus',
...overrides,
});
const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): PluginDashboard => ({
dashboardId: 1,
uid: 'dash-uid',
title: 'Test Dashboard',
pluginId: 'prometheus',
path: 'dashboards/test.json',
description: 'Test plugin dashboard',
imported: false,
importedRevision: 0,
importedUri: '',
importedUrl: '',
removed: false,
revision: 1,
slug: 'test-dashboard',
...overrides,
});
const createMockGnetDashboardWithDownloads = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => {
return createMockGnetDashboard({ ...overrides, downloads: 10000 });
};
const defaultFetchParams: FetchCommunityDashboardsParams = {
orderBy: 'downloads',
@@ -80,8 +67,54 @@ describe('dashboardLibraryApi', () => {
});
describe('fetchCommunityDashboards', () => {
describe('filterNotSafeDashboards', () => {
it('should filter out dashboards with panel types that can contain JavaScript code', async () => {
const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 });
const mockDashboards = [
safeDashboard,
createMockGnetDashboardWithDownloads({ id: 2, panelTypeSlugs: ['ae3e-plotly-panel'] }),
];
const mockResponse = {
page: 1,
pages: 5,
items: mockDashboards,
};
mockGet.mockResolvedValue(mockResponse);
const result = await fetchCommunityDashboards(defaultFetchParams);
expect(result).toEqual({
page: 1,
pages: 5,
items: [safeDashboard],
});
});
it('should filter out dashboards with low downloads', async () => {
const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 });
const mockDashboards = [safeDashboard, createMockGnetDashboard({ id: 2, downloads: 999 })];
const mockResponse = {
page: 1,
pages: 5,
items: mockDashboards,
};
mockGet.mockResolvedValue(mockResponse);
const result = await fetchCommunityDashboards(defaultFetchParams);
expect(result).toEqual({
page: 1,
pages: 5,
items: [safeDashboard],
});
});
});
it('should fetch community dashboards with correct query parameters', async () => {
const mockDashboards = [createMockGnetDashboard({ id: 1 }), createMockGnetDashboard({ id: 2 })];
const mockDashboards = [
createMockGnetDashboardWithDownloads({ id: 1 }),
createMockGnetDashboardWithDownloads({ id: 2 }),
];
const mockResponse = {
page: 1,
pages: 5,
@@ -93,7 +126,7 @@ describe('dashboardLibraryApi', () => {
const result = await fetchCommunityDashboards(defaultFetchParams);
expect(mockGet).toHaveBeenCalledWith(
'/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true',
'/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true&includePanelTypeSlugs=true',
undefined,
undefined,
{ showErrorAlert: false }
@@ -154,7 +187,7 @@ describe('dashboardLibraryApi', () => {
});
it('should use fallback values when page/pages are missing', async () => {
const items = [createMockGnetDashboard()];
const items = [createMockGnetDashboardWithDownloads()];
mockGet.mockResolvedValue({
items,

View File

@@ -2,7 +2,35 @@ import { getBackendSrv } from '@grafana/runtime';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { PluginDashboard } from 'app/types/plugins';
import { GnetDashboardsResponse, Link } from '../types';
import { GnetDashboard, GnetDashboardsResponse, Link } from '../types';
/**
* Panel types that are known to allow JavaScript code execution.
* These panels are filtered out due to security concerns.
*/
const UNSAFE_PANEL_TYPE_SLUGS = [
'aceiot-svg-panel',
'ae3e-plotly-panel',
'gapit-htmlgraphics-panel',
'marcusolsson-dynamictext-panel',
'volkovlabs-echarts-panel',
'volkovlabs-form-panel',
];
/**
* Minimum number of downloads required for a community dashboard to be shown as a suggestion.
*
* Rationale:
* - Dashboards with higher download counts have been vetted by a larger community
* - This acts as a heuristic for quality and trustworthiness
* - Reduces risk of malicious or poorly-maintained dashboards
*
* Trade-offs:
* - May filter out legitimate but less popular dashboards
* - Newer dashboards with good content but low download counts won't be shown
* - The threshold of 10,000 is somewhat arbitrary and may need tuning based on ecosystem growth
*/
const MIN_DOWNLOADS_FILTER = 10000;
/**
* Parameters for fetching community dashboards from Grafana.com
@@ -56,6 +84,7 @@ export async function fetchCommunityDashboards(
pageSize: params.pageSize.toString(),
includeLogo: params.includeLogo ? '1' : '0',
includeScreenshots: params.includeScreenshots ? 'true' : 'false',
includePanelTypeSlugs: 'true',
});
if (params.dataSourceSlugIn) {
@@ -69,13 +98,13 @@ export async function fetchCommunityDashboards(
showErrorAlert: false,
});
// Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] }
// We normalize it to use "dashboards" instead of "items" for consistency
if (result && Array.isArray(result.items)) {
const dashboards = filterNonSafeDashboards(result.items);
return {
page: result.page || params.page,
pages: result.pages || 1,
items: result.items,
items: dashboards,
};
}
@@ -109,3 +138,20 @@ export async function fetchProvisionedDashboards(datasourceType: string): Promis
return [];
}
}
// We only show dashboards with at least MIN_DOWNLOADS_FILTER downloads
// They are already ordered by downloads amount
const filterNonSafeDashboards = (dashboards: GnetDashboard[]): GnetDashboard[] => {
return dashboards.filter((item: GnetDashboard) => {
const hasUnsafePanelTypes = item.panelTypeSlugs?.some((slug: string) => UNSAFE_PANEL_TYPE_SLUGS.includes(slug));
const hasLowDownloads = typeof item.downloads === 'number' && item.downloads < MIN_DOWNLOADS_FILTER;
if (hasUnsafePanelTypes || hasLowDownloads) {
console.warn(
`Community dashboard ${item.id} ${item.name} filtered out due to low downloads ${item.downloads} or panel types ${item.panelTypeSlugs?.join(', ')} that can embed JavaScript`
);
return false;
}
return true;
});
};

View File

@@ -8,6 +8,7 @@ export const EVENT_LOCATIONS = {
MODAL_PROVISIONED_TAB: 'suggested_dashboards_modal_provisioned_tab',
MODAL_COMMUNITY_TAB: 'suggested_dashboards_modal_community_tab',
BROWSE_DASHBOARDS_PAGE: 'browse_dashboards_page',
COMMUNITY_DASHBOARD_LOADED: 'community_dashboard_loaded',
} as const;
export const CONTENT_KINDS = {

View File

@@ -24,6 +24,7 @@ export interface GnetDashboard {
id: number;
name: string;
description: string;
slug: string;
downloads: number;
datasource: string;
screenshots?: Screenshot[];
@@ -38,6 +39,7 @@ export interface GnetDashboard {
orgSlug?: string;
userId?: number;
userName?: string;
panelTypeSlugs?: string[];
}
export interface GnetDashboardsResponse {

View File

@@ -11,7 +11,6 @@ import { InputMapping, tryAutoMapDatasources, parseConstantInputs } from './auto
import {
buildDashboardDetails,
buildGrafanaComUrl,
createSlug,
getLogoUrl,
navigateToTemplate,
onUseCommunityDashboard,
@@ -27,6 +26,14 @@ jest.mock('./autoMapDatasources', () => ({
parseConstantInputs: jest.fn(),
}));
jest.mock('../interactions', () => ({
...jest.requireActual('../interactions'),
DashboardLibraryInteractions: {
...jest.requireActual('../interactions').DashboardLibraryInteractions,
communityDashboardFiltered: jest.fn(),
},
}));
// Mock function references
const mockFetchCommunityDashboard = fetchCommunityDashboard as jest.MockedFunction<typeof fetchCommunityDashboard>;
const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction<typeof tryAutoMapDatasources>;
@@ -43,6 +50,7 @@ const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDa
publishedAt: '',
updatedAt: '2025-11-05T16:55:41.000Z',
downloads: 0,
slug: 'test-dashboard',
...overrides,
});
@@ -61,25 +69,11 @@ const createMockDashboardJson = (overrides: Partial<DashboardJson> = {}): Dashbo
}) as DashboardJson;
describe('communityDashboardHelpers', () => {
describe('createSlug', () => {
it('should convert to lower case', () => {
expect(createSlug('Test')).toBe('test');
});
it('should replace non-alphanumeric characters with hyphens', () => {
expect(createSlug('Test@#example')).toBe('test-example');
});
it('should remove leading and trailing hyphens', () => {
expect(createSlug('-test-')).toBe('test');
});
});
describe('buildGrafanaComUrl', () => {
it('should build a valid URL', () => {
const gnetDashboard = createMockGnetDashboard({
id: 1,
name: 'Test',
slug: 'test',
});
expect(buildGrafanaComUrl(gnetDashboard)).toBe('https://grafana.com/grafana/dashboards/1-test/');
@@ -91,6 +85,7 @@ describe('communityDashboardHelpers', () => {
const gnetDashboard = createMockGnetDashboard({
id: 1,
name: 'Test',
slug: 'test',
datasource: 'Test',
orgName: 'Org',
updatedAt: '2025-11-05T16:55:41.000Z',
@@ -170,6 +165,10 @@ describe('communityDashboardHelpers', () => {
});
describe('onUseCommunityDashboard', () => {
let consoleWarnSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
let locationServicePushSpy: jest.SpyInstance;
async function setup(options?: {
dashboard?: Partial<GnetDashboard>;
dashboardJson?: Partial<DashboardJson>;
@@ -206,7 +205,16 @@ describe('communityDashboardHelpers', () => {
}
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
locationServicePushSpy = jest.spyOn(locationService, 'push').mockImplementation();
});
afterEach(() => {
jest.clearAllMocks();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
locationServicePushSpy.mockRestore();
});
it('should navigate directly when all datasources are auto-mapped and no constants', async () => {
@@ -218,8 +226,8 @@ describe('communityDashboardHelpers', () => {
},
});
expect(locationService.push).toHaveBeenCalled();
expect(locationService.push).toHaveBeenCalledWith(
expect(locationServicePushSpy).toHaveBeenCalled();
expect(locationServicePushSpy).toHaveBeenCalledWith(
expect.objectContaining({
pathname: expect.any(String),
search: expect.stringContaining('gnetId=123'),
@@ -249,7 +257,7 @@ describe('communityDashboardHelpers', () => {
});
expect(mockOnShowMapping).toHaveBeenCalled();
expect(locationService.push).not.toHaveBeenCalled();
expect(locationServicePushSpy).not.toHaveBeenCalled();
expect(mockOnShowMapping).toHaveBeenCalledWith(
expect.objectContaining({
dashboardName: 'Test Dashboard',
@@ -281,7 +289,7 @@ describe('communityDashboardHelpers', () => {
});
expect(mockOnShowMapping).toHaveBeenCalled();
expect(locationService.push).not.toHaveBeenCalled();
expect(locationServicePushSpy).not.toHaveBeenCalled();
expect(mockOnShowMapping).toHaveBeenCalledWith(
expect.objectContaining({
dashboardName: 'Test Dashboard',
@@ -294,17 +302,312 @@ describe('communityDashboardHelpers', () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockFetchCommunityDashboard.mockRejectedValue(new Error('API failed'));
await onUseCommunityDashboard({
dashboard: createMockGnetDashboard(),
datasourceUid: 'test-ds-uid',
datasourceType: 'prometheus',
eventLocation: 'empty_dashboard',
});
await expect(
onUseCommunityDashboard({
dashboard: createMockGnetDashboard(),
datasourceUid: 'test-ds-uid',
datasourceType: 'prometheus',
eventLocation: 'empty_dashboard',
})
).rejects.toThrow('API failed');
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationService.push).not.toHaveBeenCalled();
expect(locationServicePushSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
describe('when the dashboard contains JavaScript code', () => {
it('should throw an error if the dashboard contains JavaScript code in options', async () => {
const dashboardJson = createMockDashboardJson({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
panels: [{ type: 'panel', options: { template: '{{ javascript:alert("XSS") }}' } } as any],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains JavaScript code in targets/queries', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {},
targets: [
{
expr: 'function() { return bad(); }',
refId: 'A',
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains JavaScript code in transformations', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {},
transformations: [
{
id: 'calculateField',
options: {
mode: 'binary',
binary: {
reducer: 'sum',
left: 'A',
right: 'B',
},
replaceFields: false,
alias: 'function() { alert("XSS"); }',
},
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains JavaScript code in fieldConfig', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {},
fieldConfig: {
defaults: {
custom: {
displayMode: 'function() { return "bad"; }',
},
},
overrides: [],
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains javascript: URLs in links', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {},
links: [
{
title: 'Bad Link',
url: 'javascript:alert("XSS")',
targetBlank: false,
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains <script> tags in any property', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {
content: '<script>alert("XSS")</script>',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains arrow functions', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {
customCode: '() => { alert("XSS"); }',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains setTimeout or setInterval', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {
handler: 'setTimeout(() => alert("XSS"), 1000)',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains suspicious key names like beforeRender', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {},
beforeRender: 'alert("XSS")',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains suspicious key names like afterRender', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {},
afterRender: 'alert("XSS")',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains suspicious key names like handler', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {},
handler: 'alert("XSS")',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains return statements', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {
customLogic: 'function test() { return malicious(); }',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
it('should throw an error if the dashboard contains event handlers like onclick', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {
html: '<div onclick="alert(\'XSS\')">Click me</div>',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
});
await expect(setup({ dashboardJson })).rejects.toThrow(
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
expect(locationServicePushSpy).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,5 +1,11 @@
import { PanelModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { dispatch } from 'app/types/store';
import { DASHBOARD_LIBRARY_ROUTES } from '../../types';
import { MappingContext } from '../SuggestedDashboardsModal';
@@ -9,6 +15,12 @@ import { GnetDashboard, Link } from '../types';
import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources';
// Constants for community dashboard pagination and API params
// We want to get the most 6 downloaded dashboards, but we first query 12
// to be sure the next filters we apply to that list doesn not reduce it below 6
export const COMMUNITY_PAGE_SIZE_QUERY = 12;
export const COMMUNITY_RESULT_SIZE = 6;
/**
* Extract thumbnail URL from dashboard screenshots
*/
@@ -39,21 +51,11 @@ export function formatDate(dateString?: string): string {
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
/**
* Create URL-friendly slug from dashboard name
*/
export function createSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Build Grafana.com URL for a dashboard
*/
export function buildGrafanaComUrl(dashboard: GnetDashboard): string {
return `https://grafana.com/grafana/dashboards/${dashboard.id}-${createSlug(dashboard.name)}/`;
return `https://grafana.com/grafana/dashboards/${dashboard.id}-${dashboard.slug}/`;
}
/**
@@ -121,12 +123,110 @@ interface UseCommunityDashboardParams {
onShowMapping?: (context: MappingContext) => void;
}
/**
* Check if a panel contains JavaScript code using heuristic pattern matching.
*
* IMPORTANT: This is a heuristic-based detection, not a perfect mechanism.
*
* Patterns checked:
* - HTML/Script tags: Direct XSS attack vectors
* - Event handlers: Common JS injection points (onclick, onload, etc.)
* - Function declarations: Actual executable code patterns
* - eval/Function constructor: Dynamic code execution
* - setTimeout/setInterval: Deferred code execution
*
* What we DON'T check:
* - Panel title and description are excluded (already sanitized by Grafana's rendering layer)
* - Only the panel's options and configuration are scanned
*
* @param panel - The panel model to check
* @returns true if the panel might contain JavaScript code, false otherwise
*/
function canPanelContainJS(panel: PanelModel): boolean {
// Create a copy of the panel without title and description, as they are already sanitized
// This reduces false positives while still checking all other properties for JavaScript code
const { title, description, ...panelWithoutSanitizedFields } = panel;
let panelJson: string;
try {
panelJson = JSON.stringify(panelWithoutSanitizedFields);
} catch (e) {
console.warn('Failed to stringify panel', e);
return true;
}
// Patterns that indicate actual JavaScript code in values
const valuePatterns = [
/<script\b/i, // HTML script tags
/\bon\w+\s*=\s*/i, // HTML event handlers: onclick=, onload=, etc.
/\bjavascript\s*:/i,
/\bfunction\s*\(/, // Anonymous function declarations: function(
/\bfunction\s+[\w$]+\s*\(/, // Named function declarations: function name(
/=>\s*\{[^}]*\breturn\b/, // Arrow function with return statement: () => { return ... }
/\beval\s*\(/i, // eval() calls
/\bnew\s+Function\s*\(/i, // new Function() constructor
/\bsetTimeout\s*\(/i, // setTimeout calls
/\bsetInterval\s*\(/i, // setInterval calls
];
// Patterns for suspicious JSON keys that might indicate JS hooks
const keyPatterns = [
/"on[a-zA-Z]+"\s*:/, // Event handlers as keys (both camelCase and lowercase): "onClick": or "onclick":
/"beforeRender"\s*:/i, // beforeRender hook as JSON key
/"afterRender"\s*:/i, // afterRender hook as JSON key
/"javascript"\s*:/i, // "javascript" as a key
/"customCode"\s*:/i, // Common pattern for custom code injection
/"script"\s*:/i, // "script" as a JSON key
/"handler"\s*:/i, // "handler" as a JSON key - common for event handlers
];
const hasSuspiciousValue = valuePatterns.some((pattern) => {
if (pattern.test(panelJson)) {
console.warn('Panel contains JavaScript code in value');
return true;
}
return false;
});
const hasSuspiciousKey = keyPatterns.some((pattern) => {
if (pattern.test(panelJson)) {
console.warn('Panel contains JavaScript code in key');
return true;
}
return false;
});
return hasSuspiciousValue || hasSuspiciousKey;
}
function isPanelModel(panel: unknown): panel is PanelModel {
if (!panel || typeof panel !== 'object') {
return false;
}
return 'options' in panel && 'type' in panel;
}
/**
* Check if a dashboard contains JavaScript code. This is not a perfect check, but good enough
* Used as a second filter after the first filter of panel types (see api/dashboardLibraryApi.ts)
*/
const canDashboardContainJS = (dashboard: DashboardJson): boolean => {
return dashboard.panels?.some((panel) => {
// Skip library panels - they don't have options/type and are already validated
if (isPanelModel(panel)) {
return canPanelContainJS(panel);
}
return false;
});
};
/**
* Handles the flow when a user selects a community dashboard:
* 1. Tracks analytics
* 2. Fetches full dashboard JSON with __inputs
* 3. Attempts auto-mapping of datasources
* 4. Either navigates directly or shows mapping form
* 3. Filters out dashboards that contain JavaScript code due to security reasons
* 4. Attempts auto-mapping of datasources
* 5. Either navigates directly or shows mapping form
*/
export async function onUseCommunityDashboard({
dashboard,
@@ -142,6 +242,10 @@ export async function onUseCommunityDashboard({
const fullDashboard = await fetchCommunityDashboard(dashboard.id);
const dashboardJson = fullDashboard.json;
if (canDashboardContainJS(dashboardJson)) {
throw new Error(`Community dashboard ${dashboard.id} "${dashboard.name}" might contain JavaScript code`);
}
// Parse datasource requirements from __inputs
const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || [];
@@ -199,6 +303,11 @@ export async function onUseCommunityDashboard({
}
} catch (err) {
console.error('Error loading community dashboard:', err);
// TODO: Show error notification
dispatch(
notifyApp(
createErrorNotification(t('dashboard-library.community-error-title', 'Error loading community dashboard'))
)
);
throw err;
}
}

View File

@@ -0,0 +1,34 @@
import { PluginDashboard } from 'app/types/plugins';
import { GnetDashboard } from '../types';
export const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): PluginDashboard => ({
dashboardId: 1,
uid: 'dash-uid',
title: 'Test Provisioned Dashboard',
description: 'Test plugin dashboard',
path: 'dashboards/test.json',
pluginId: 'prometheus',
imported: false,
importedRevision: 0,
importedUri: '',
importedUrl: '',
removed: false,
revision: 1,
slug: 'test-dashboard',
...overrides,
});
export const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 123,
name: 'Test Dashboard',
description: 'Test description',
datasource: 'Prometheus',
orgName: 'Test Org',
userName: 'testuser',
publishedAt: '',
updatedAt: '',
downloads: 0,
slug: 'test-dashboard',
...overrides,
});

View File

@@ -64,7 +64,7 @@ export function useDatasource(dataSource: string | DataSourceRef | DataSourceIns
export interface KeybaordNavigatableListProps {
keyboardEvents?: Observable<React.KeyboardEvent>;
containerRef: React.RefObject<HTMLElement>;
containerRef: React.RefObject<HTMLElement | null>;
}
/**

View File

@@ -25,7 +25,7 @@ interface State {
}
export class ThresholdsEditor extends PureComponent<Props, State> {
private latestThresholdInputRef: React.RefObject<HTMLInputElement>;
private latestThresholdInputRef: React.RefObject<HTMLInputElement | null>;
constructor(props: Props) {
super(props);

View File

@@ -46,6 +46,9 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
);
const styles = useStyles2(getStyles, contentOutlineExpanded);
const scrollerRef = useRef(scroller || null);
// TODO remove when react-use is fixed
// see https://github.com/streamich/react-use/issues/2612
// @ts-expect-error
const { y: verticalScroll } = useScroll(scrollerRef);
const { outlineItems } = useContentOutlineContext() ?? { outlineItems: [] };
const [activeSectionId, setActiveSectionId] = useState(outlineItems[0]?.id);

View File

@@ -74,7 +74,7 @@ export const ExplorePaneContainer = connector(ExplorePaneContainerUnconnected);
function useStopQueries(exploreId: string) {
const paneSelector = useMemo(() => getExploreItemSelector(exploreId), [exploreId]);
const paneRef = useRef<ReturnType<typeof paneSelector>>();
const paneRef = useRef<ReturnType<typeof paneSelector>>(null);
paneRef.current = useSelector(paneSelector);
useEffect(() => {

View File

@@ -44,7 +44,7 @@ export type QueryLibraryContextType = {
onUpdateSuccess?: () => void,
onSelectQuery?: (query: DataQuery) => void,
datasourceFilters?: string[],
parentRef?: React.RefObject<HTMLDivElement>
parentRef?: React.RefObject<HTMLDivElement | null>
) => ReactNode;
/**

View File

@@ -63,7 +63,7 @@ type Props = {
scrollElementClass?: string;
traceProp: Trace;
datasource: DataSourceApi<DataQuery, DataSourceJsonData, {}> | undefined;
topOfViewRef?: RefObject<HTMLDivElement>;
topOfViewRef?: RefObject<HTMLDivElement | null>;
createSpanLink?: SpanLinkFunc;
focusedSpanId?: string;
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>;

View File

@@ -27,7 +27,8 @@ const timeRange = {
to: new Date(1000),
} as unknown as TimeRange;
function getContent(result: React.ReactElement) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getContent(result: React.ReactElement<any>) {
return result.props.children.props.children[0];
}

View File

@@ -561,7 +561,7 @@ const CardsContainer = ({
mainContainerRef,
}: {
listOfContentCards: React.ReactNode[];
mainContainerRef?: React.RefObject<HTMLDivElement>;
mainContainerRef?: React.RefObject<HTMLDivElement | null>;
}) => {
const styles = useStyles2(getStyles);

Some files were not shown because too many files have changed in this diff Show More