Geomap: Add variable support for GeoJSON url (#104587)

* Geomap: Add variable support for GeoJSON url

* Add a check for variable dependency

* Add tests for hasVariableDependencies function

* Update docs

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

---------

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
This commit is contained in:
Drew Slobodnjak 2025-05-08 09:17:03 -07:00 committed by GitHub
parent a049ddece7
commit 3dc5742a75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 140 additions and 3 deletions

View File

@ -313,7 +313,7 @@ The GeoJSON layer allows you to select and load a static GeoJSON file from the f
<!-- prettier-ignore-start -->
| Option | Description |
| ------ | ----------- |
| GeoJSON URL | Provides a choice of GeoJSON files that ship with Grafana. |
| GeoJSON URL | Provides a choice of GeoJSON files that are included with Grafana. You can also enter a URL manually, which supports variables. |
| Default Style | Controls which styles to apply when no rules above match.<ul><li>**Color** - configures the color of the default style</li><li>**Opacity** - configures the default opacity</li></ul> |
| Style Rules | Apply styles based on feature properties <ul><li>**Rule** - allows you to select a _feature_, _condition_, and _value_ from the GeoJSON file in order to define a rule. The trash bin icon can be used to delete the current rule.</li><li>**Color** - configures the color of the style for the current rule</li><li>**Opacity** - configures the transparency level for the current rule</li> |
| Display tooltip | Allows you to toggle tooltips for the layer. |

View File

@ -15,6 +15,8 @@ import { Subscription } from 'rxjs';
import { DataHoverEvent, PanelData, PanelProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import { PanelContext, PanelContextRoot } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { VariablesChanged } from 'app/features/variables/types';
import { PanelEditExitedEvent } from 'app/types/events';
import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
@ -31,7 +33,7 @@ import { getActions } from './utils/actions';
import { getLayersExtent } from './utils/getLayersExtent';
import { applyLayerFilter, initLayer } from './utils/layers';
import { pointerClickListener, pointerMoveListener, setTooltipListeners } from './utils/tooltip';
import { updateMap, getNewOpenLayersMap, notifyPanelEditor } from './utils/utils';
import { updateMap, getNewOpenLayersMap, notifyPanelEditor, hasVariableDependencies } from './utils/utils';
import { centerPointRegistry, MapCenterID } from './view';
// Allows multiple panels to share the same view instance
@ -72,6 +74,25 @@ export class GeomapPanel extends Component<Props, State> {
}
})
);
// Subscribe to variable changes
this.subs.add(
appEvents.subscribe(VariablesChanged, () => {
if (this.mapDiv) {
// Check if any of the map's layers are dependent on variables
const hasDependencies = this.layers.some((layer) => {
const config = layer.options.config;
if (!config || typeof config !== 'object') {
return false;
}
return hasVariableDependencies(config);
});
if (hasDependencies) {
this.initMapRef(this.mapDiv);
}
}
})
);
}
componentDidMount() {

View File

@ -9,6 +9,7 @@ import { ReplaySubject } from 'rxjs';
import { map as rxjsmap, first } from 'rxjs/operators';
import { MapLayerRegistryItem, MapLayerOptions, GrafanaTheme2, EventBus } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { ComparisonOperation } from '@grafana/schema';
import { GeomapStyleRulesEditor } from '../../editor/GeomapStyleRulesEditor';
@ -75,8 +76,11 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = {
create: async (map: Map, options: MapLayerOptions<GeoJSONMapperConfig>, eventBus: EventBus, theme: GrafanaTheme2) => {
const config = { ...defaultOptions, ...options.config };
// Interpolate variables in the URL
const interpolatedUrl = getTemplateSrv().replace(config.src || '');
const source = new VectorSource({
url: config.src,
url: interpolatedUrl,
format: new GeoJSON(),
});
@ -194,6 +198,7 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = {
settings: {
options: getPublicGeoJSONFiles() ?? [],
allowCustomValue: true,
supportVariables: true,
},
defaultValue: defaultOptions.src,
})

View File

@ -0,0 +1,101 @@
import { getTemplateSrv } from '@grafana/runtime';
// Mock the config module to avoid undefined panels error
jest.mock('app/core/config', () => ({
config: {
panels: {
debug: {
state: 'alpha',
},
},
},
}));
// Mock the dimensions module since it's imported by utils.ts
jest.mock('app/features/dimensions', () => ({
getColorDimension: jest.fn(),
getScalarDimension: jest.fn(),
getScaledDimension: jest.fn(),
getTextDimension: jest.fn(),
}));
// Mock the grafana datasource since it's imported by utils.ts
jest.mock('app/plugins/datasource/grafana/datasource', () => ({
getGrafanaDatasource: jest.fn(),
}));
// Mock the template service
jest.mock('@grafana/runtime', () => ({
getTemplateSrv: jest.fn(),
}));
import { hasVariableDependencies } from './utils';
describe('hasVariableDependencies', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return true when object contains existing template variables', () => {
const availableVariables = [{ name: 'variable' }];
const mockTemplateSrv = {
containsTemplate: jest.fn().mockImplementation((str) => {
// Check if any of the available variables are in the string
return availableVariables.some((v) => str.includes(`$${v.name}`));
}),
getVariables: jest.fn().mockReturnValue(availableVariables),
};
(getTemplateSrv as jest.Mock).mockReturnValue(mockTemplateSrv);
const obj = { key: '$variable' };
expect(hasVariableDependencies(obj)).toBe(true);
expect(mockTemplateSrv.containsTemplate).toHaveBeenCalledWith(JSON.stringify(obj));
});
it('should return false when object contains non-existent template variables', () => {
const availableVariables = [{ name: 'variable' }];
const mockTemplateSrv = {
containsTemplate: jest.fn().mockImplementation((str) => {
return availableVariables.some((v) => str.includes(`$${v.name}`));
}),
getVariables: jest.fn().mockReturnValue(availableVariables),
};
(getTemplateSrv as jest.Mock).mockReturnValue(mockTemplateSrv);
const obj = { key: '$nonexistent' };
expect(hasVariableDependencies(obj)).toBe(false);
expect(mockTemplateSrv.containsTemplate).toHaveBeenCalledWith(JSON.stringify(obj));
});
it('should return false when object does not contain template variables', () => {
const mockTemplateSrv = {
containsTemplate: jest.fn().mockReturnValue(false),
getVariables: jest.fn().mockReturnValue([]),
};
(getTemplateSrv as jest.Mock).mockReturnValue(mockTemplateSrv);
const obj = { key: 'static value' };
expect(hasVariableDependencies(obj)).toBe(false);
expect(mockTemplateSrv.containsTemplate).toHaveBeenCalledWith(JSON.stringify(obj));
});
it('should handle nested objects with existing template variables', () => {
const availableVariables = [{ name: 'variable' }];
const mockTemplateSrv = {
containsTemplate: jest.fn().mockImplementation((str) => {
return availableVariables.some((v) => str.includes(`$${v.name}`));
}),
getVariables: jest.fn().mockReturnValue(availableVariables),
};
(getTemplateSrv as jest.Mock).mockReturnValue(mockTemplateSrv);
const obj = {
key: 'static value',
nested: {
anotherKey: '$variable',
},
};
expect(hasVariableDependencies(obj)).toBe(true);
expect(mockTemplateSrv.containsTemplate).toHaveBeenCalledWith(JSON.stringify(obj));
});
});

View File

@ -2,6 +2,7 @@ import { Map as OpenLayersMap } from 'ol';
import { defaults as interactionDefaults } from 'ol/interaction';
import { DataFrame, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { getColorDimension, getScalarDimension, getScaledDimension, getTextDimension } from 'app/features/dimensions';
import { getGrafanaDatasource } from 'app/plugins/datasource/grafana/datasource';
@ -73,6 +74,15 @@ async function initGeojsonFiles() {
}
}
/**
* Checks if an object contains any Grafana template variables
* @param obj - The object to check for variables
* @returns true if the object contains any template variables
*/
export const hasVariableDependencies = (obj: object): boolean => {
return getTemplateSrv().containsTemplate(JSON.stringify(obj));
};
export const getNewOpenLayersMap = (panel: GeomapPanel, options: Options, div: HTMLDivElement) => {
const view = panel.initMapView(options.view);
return (panel.map = new OpenLayersMap({