mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Geomap: Improve location editor (#58017)
* add custom component for location editor * FC cleanup * Apply filter to add location fields call * Create custom editor for location mode * Apply validation logic and render warning * Improve alert styling * Add help url button to location alert * Add success alert for auto * Remove completed TODOs * Only use alert on error, not success * Change location mode to dropdown * Change alert severity to less severe, info * Prevent auto field selection during manual * Update location testing to be for auto mode * Run geo transformer editor init once * Fix breaking test * Clean up some anys * Update styling for alert * Remove auto success styling Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
parent
b875ca08c6
commit
ee8f292c6a
@ -3995,12 +3995,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/folders/state/actions.test.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/geo/editor/GazetteerPathEditor.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"public/app/features/geo/format/geohash.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FC, useMemo, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
|
||||
import { StandardEditorProps, SelectableValue, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Select, stylesFactory, useTheme2 } from '@grafana/ui';
|
||||
@ -28,15 +28,15 @@ export interface GazetteerPathEditorConfigSettings {
|
||||
options?: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
export const GazetteerPathEditor: FC<StandardEditorProps<string, any, any, GazetteerPathEditorConfigSettings>> = ({
|
||||
export const GazetteerPathEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
context,
|
||||
item,
|
||||
}) => {
|
||||
}: StandardEditorProps<string, GazetteerPathEditorConfigSettings>) => {
|
||||
const styles = getStyles(useTheme2());
|
||||
const [gaz, setGaz] = useState<Gazetteer>();
|
||||
const settings = item.settings as any;
|
||||
const settings = item.settings;
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
|
@ -4,47 +4,28 @@ import {
|
||||
FrameGeometrySource,
|
||||
FrameGeometrySourceMode,
|
||||
PanelOptionsEditorBuilder,
|
||||
DataFrame,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors/src';
|
||||
import { GazetteerPathEditor } from 'app/features/geo/editor/GazetteerPathEditor';
|
||||
|
||||
import { LocationModeEditor } from './locationModeEditor';
|
||||
|
||||
export function addLocationFields<TOptions>(
|
||||
title: string,
|
||||
prefix: string,
|
||||
builder: PanelOptionsEditorBuilder<TOptions>,
|
||||
source?: FrameGeometrySource
|
||||
builder: PanelOptionsEditorBuilder<TOptions>, // ??? Perhaps pass in the filtered data?
|
||||
source?: FrameGeometrySource,
|
||||
data?: DataFrame[]
|
||||
) {
|
||||
builder.addRadio({
|
||||
builder.addCustomEditor({
|
||||
id: 'modeEditor',
|
||||
path: `${prefix}mode`,
|
||||
name: title,
|
||||
description: '',
|
||||
defaultValue: FrameGeometrySourceMode.Auto,
|
||||
settings: {
|
||||
options: [
|
||||
{
|
||||
value: FrameGeometrySourceMode.Auto,
|
||||
label: 'Auto',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.autoOption,
|
||||
},
|
||||
{
|
||||
value: FrameGeometrySourceMode.Coords,
|
||||
label: 'Coords',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.coords.option,
|
||||
},
|
||||
{
|
||||
value: FrameGeometrySourceMode.Geohash,
|
||||
label: 'Geohash',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.geohash.option,
|
||||
},
|
||||
{
|
||||
value: FrameGeometrySourceMode.Lookup,
|
||||
label: 'Lookup',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.lookup.option,
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'Location Mode',
|
||||
editor: LocationModeEditor,
|
||||
settings: { data, source },
|
||||
});
|
||||
|
||||
// TODO apply data filter to field pickers
|
||||
switch (source?.mode) {
|
||||
case FrameGeometrySourceMode.Coords:
|
||||
builder
|
||||
|
126
public/app/features/geo/editor/locationModeEditor.tsx
Normal file
126
public/app/features/geo/editor/locationModeEditor.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
StandardEditorProps,
|
||||
FrameGeometrySourceMode,
|
||||
DataFrame,
|
||||
FrameGeometrySource,
|
||||
GrafanaTheme2,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Alert, HorizontalGroup, Icon, Select, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { FrameGeometryField, getGeometryField, getLocationMatchers } from '../utils/location';
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{
|
||||
value: FrameGeometrySourceMode.Auto,
|
||||
label: 'Auto',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.autoOption,
|
||||
description: 'Automatically identify location data based on default field names',
|
||||
},
|
||||
{
|
||||
value: FrameGeometrySourceMode.Coords,
|
||||
label: 'Coords',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.coords.option,
|
||||
description: 'Specify latitude and longitude fields',
|
||||
},
|
||||
{
|
||||
value: FrameGeometrySourceMode.Geohash,
|
||||
label: 'Geohash',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.geohash.option,
|
||||
description: 'Specify geohash field',
|
||||
},
|
||||
{
|
||||
value: FrameGeometrySourceMode.Lookup,
|
||||
label: 'Lookup',
|
||||
ariaLabel: selectors.components.Transforms.SpatialOperations.location.lookup.option,
|
||||
description: 'Specify Gazetteer and lookup field',
|
||||
},
|
||||
];
|
||||
|
||||
interface ModeEditorSettings {
|
||||
data?: DataFrame[];
|
||||
source?: FrameGeometrySource;
|
||||
}
|
||||
|
||||
const helpUrl = 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/geomap/#location';
|
||||
|
||||
export const LocationModeEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
context,
|
||||
item,
|
||||
}: StandardEditorProps<string, ModeEditorSettings, unknown, unknown>) => {
|
||||
const [info, setInfo] = useState<FrameGeometryField>();
|
||||
|
||||
useEffect(() => {
|
||||
if (item.settings?.source && item.settings?.data?.length && item.settings.data[0]) {
|
||||
getLocationMatchers(item.settings.source).then((location) => {
|
||||
if (item.settings && item.settings.data) {
|
||||
setInfo(getGeometryField(item.settings.data[0], location));
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [item.settings]);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const dataValidation = () => {
|
||||
if (info) {
|
||||
if (info.warning) {
|
||||
return (
|
||||
<Alert
|
||||
title={info.warning}
|
||||
severity="warning"
|
||||
buttonContent={<Icon name="question-circle" size="xl" />}
|
||||
className={styles.alert}
|
||||
onRemove={() => {
|
||||
const newWindow = window.open(helpUrl, '_blank', 'noopener,noreferrer');
|
||||
if (newWindow) {
|
||||
newWindow.opener = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (value === FrameGeometrySourceMode.Auto && info.description) {
|
||||
return <span>{info.description}</span>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
options={MODE_OPTIONS}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v.value);
|
||||
}}
|
||||
/>
|
||||
<HorizontalGroup className={styles.hGroup}>{dataValidation()}</HorizontalGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
alert: css`
|
||||
& div {
|
||||
padding: 4px;
|
||||
}
|
||||
margin-bottom: 0px;
|
||||
margin-top: 5px;
|
||||
padding: 2px;
|
||||
`,
|
||||
// TODO apply styling to horizontal group (currently not working)
|
||||
hGroup: css`
|
||||
& div {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -11,6 +11,10 @@ const geohash = ['9q94r', 'dr5rs'];
|
||||
const names = ['A', 'B'];
|
||||
|
||||
describe('handle location parsing', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'warn').mockImplementation();
|
||||
});
|
||||
|
||||
it('auto should find geohash field', async () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'simple',
|
||||
@ -20,7 +24,7 @@ describe('handle location parsing', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const matchers = await getLocationMatchers();
|
||||
const matchers = await getLocationMatchers({ mode: FrameGeometrySourceMode.Auto });
|
||||
const fields = getLocationFields(frame, matchers);
|
||||
expect(fields.mode).toEqual(FrameGeometrySourceMode.Geohash);
|
||||
expect(fields.geohash).toBeDefined();
|
||||
@ -78,7 +82,7 @@ describe('handle location parsing', () => {
|
||||
});
|
||||
|
||||
const matchers = await getLocationMatchers({
|
||||
mode: FrameGeometrySourceMode.Geohash,
|
||||
mode: FrameGeometrySourceMode.Auto,
|
||||
});
|
||||
const geo = getGeometryField(frame, matchers).field!;
|
||||
expect(geo.values.toArray().map((p) => toLonLat((p as Point).getCoordinates()))).toMatchInlineSnapshot(`
|
||||
|
@ -73,24 +73,32 @@ export async function getLocationMatchers(src?: FrameGeometrySource): Promise<Lo
|
||||
...defaultMatchers,
|
||||
mode: src?.mode ?? FrameGeometrySourceMode.Auto,
|
||||
};
|
||||
info.gazetteer = await getGazetteer(src?.gazetteer); // Always have gazetteer selected (or default) for smooth transition
|
||||
switch (info.mode) {
|
||||
case FrameGeometrySourceMode.Geohash:
|
||||
if (src?.geohash) {
|
||||
info.geohash = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.geohash }));
|
||||
} else {
|
||||
info.geohash = () => undefined; // In manual mode, don't automatically find field
|
||||
}
|
||||
break;
|
||||
case FrameGeometrySourceMode.Lookup:
|
||||
if (src?.lookup) {
|
||||
info.lookup = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.lookup }));
|
||||
} else {
|
||||
info.lookup = () => undefined; // In manual mode, don't automatically find field
|
||||
}
|
||||
info.gazetteer = await getGazetteer(src?.gazetteer);
|
||||
break;
|
||||
case FrameGeometrySourceMode.Coords:
|
||||
if (src?.latitude) {
|
||||
info.latitude = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.latitude }));
|
||||
} else {
|
||||
info.latitude = () => undefined; // In manual mode, don't automatically find field
|
||||
}
|
||||
if (src?.longitude) {
|
||||
info.longitude = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.longitude }));
|
||||
} else {
|
||||
info.longitude = () => undefined; // In manual mode, don't automatically find field
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -132,7 +140,7 @@ export function getLocationFields(frame: DataFrame, location: LocationFieldMatch
|
||||
fields.mode = FrameGeometrySourceMode.Geohash;
|
||||
return fields;
|
||||
}
|
||||
fields.lookup = location.geohash(frame);
|
||||
fields.lookup = location.lookup(frame);
|
||||
if (fields.lookup) {
|
||||
fields.mode = FrameGeometrySourceMode.Lookup;
|
||||
return fields;
|
||||
@ -159,6 +167,7 @@ export interface FrameGeometryField {
|
||||
field?: Field<Geometry | undefined>;
|
||||
warning?: string;
|
||||
derived?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function getGeometryField(frame: DataFrame, location: LocationFieldMatchers): FrameGeometryField {
|
||||
@ -179,10 +188,11 @@ export function getGeometryField(frame: DataFrame, location: LocationFieldMatche
|
||||
return {
|
||||
field: pointFieldFromLonLat(fields.longitude, fields.latitude),
|
||||
derived: true,
|
||||
description: `${fields.mode}: ${fields.latitude.name}, ${fields.longitude.name}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
warning: 'Missing latitude/longitude fields',
|
||||
warning: 'Select latitude/longitude fields',
|
||||
};
|
||||
|
||||
case FrameGeometrySourceMode.Geohash:
|
||||
@ -190,10 +200,11 @@ export function getGeometryField(frame: DataFrame, location: LocationFieldMatche
|
||||
return {
|
||||
field: pointFieldFromGeohash(fields.geohash),
|
||||
derived: true,
|
||||
description: `${fields.mode}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
warning: 'Missing geohash field',
|
||||
warning: 'Select geohash field',
|
||||
};
|
||||
|
||||
case FrameGeometrySourceMode.Lookup:
|
||||
@ -202,6 +213,7 @@ export function getGeometryField(frame: DataFrame, location: LocationFieldMatche
|
||||
return {
|
||||
field: getGeoFieldFromGazetteer(location.gazetteer, fields.lookup),
|
||||
derived: true,
|
||||
description: `${fields.mode}: ${location.gazetteer.path}`, // TODO get better name for this
|
||||
};
|
||||
}
|
||||
return {
|
||||
@ -209,7 +221,7 @@ export function getGeometryField(frame: DataFrame, location: LocationFieldMatche
|
||||
};
|
||||
}
|
||||
return {
|
||||
warning: 'Missing lookup field',
|
||||
warning: 'Select lookup field',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -122,7 +122,8 @@ export const SetGeometryTransformerEditor: React.FC<TransformerUIProps<SpatialTr
|
||||
props.onChange({ ...opts, ...props.options });
|
||||
console.log('geometry useEffect', opts);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const styles = getStyles(useTheme2());
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { get as lodashGet, isEqual } from 'lodash';
|
||||
|
||||
import { FrameGeometrySourceMode, MapLayerOptions } from '@grafana/data';
|
||||
import { FrameGeometrySourceMode, getFrameMatchers, MapLayerOptions } from '@grafana/data';
|
||||
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { addLocationFields } from 'app/features/geo/editor/locationEditor';
|
||||
@ -96,7 +96,14 @@ export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions<Map
|
||||
}
|
||||
|
||||
if (layer.showLocation) {
|
||||
addLocationFields('Location', 'location.', builder, options.location);
|
||||
let data = context.data;
|
||||
// If `filterData` exists filter data feeding into location editor
|
||||
if (options.filterData) {
|
||||
const matcherFunc = getFrameMatchers(options.filterData);
|
||||
data = data.filter(matcherFunc);
|
||||
}
|
||||
|
||||
addLocationFields('Location', 'location.', builder, options.location, data);
|
||||
}
|
||||
if (handler.registerOptionsUI) {
|
||||
handler.registerOptionsUI(builder);
|
||||
|
Loading…
Reference in New Issue
Block a user