Browse Source
Icons editor See merge request chartwerk/grafana-chartwerk-panel!1merge-requests/4/merge
Alexander Velikiy
3 years ago
7 changed files with 626 additions and 32 deletions
@ -0,0 +1,222 @@ |
|||||||
|
import { IconPosition } from 'types'; |
||||||
|
|
||||||
|
import { GrafanaTheme, SelectableValue, StandardEditorProps } from '@grafana/data'; |
||||||
|
import { Button, HorizontalGroup, IconButton, Input, RadioButtonGroup, Slider, stylesFactory, ThemeContext } from '@grafana/ui'; |
||||||
|
import { FieldNamePicker } from '../../grafana/MatchersUI/FieldNamePicker'; |
||||||
|
|
||||||
|
import { css } from 'emotion'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import * as _ from 'lodash'; |
||||||
|
|
||||||
|
|
||||||
|
type IconConfig = { |
||||||
|
position: IconPosition; |
||||||
|
url: string; |
||||||
|
metrics: string[]; |
||||||
|
conditions: Condition[]; |
||||||
|
values: number[]; |
||||||
|
size: number; |
||||||
|
} |
||||||
|
|
||||||
|
enum Condition { |
||||||
|
EQUAL = '=', |
||||||
|
GREATER = '>', |
||||||
|
LESS = '<', |
||||||
|
GREATER_OR_EQUAL = '>=', |
||||||
|
LESS_OR_EQUAL = '<=', |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
const positionOptions: Array<SelectableValue<IconPosition>> = [ |
||||||
|
{ |
||||||
|
label: 'Upper-Left', |
||||||
|
value: IconPosition.UPPER_LEFT, |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: 'Middle', |
||||||
|
value: IconPosition.MIDDLE, |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: 'Upper-Right', |
||||||
|
value: IconPosition.UPPER_RIGHT, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const conditionOptions: Array<SelectableValue<Condition>> = [ |
||||||
|
{ |
||||||
|
label: '>=', |
||||||
|
value: Condition.GREATER_OR_EQUAL, |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: '>', |
||||||
|
value: Condition.GREATER, |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: '=', |
||||||
|
value: Condition.EQUAL, |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: '<', |
||||||
|
value: Condition.LESS, |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: '<=', |
||||||
|
value: Condition.LESS_OR_EQUAL, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const DEFAULT_ICON: IconConfig = { |
||||||
|
position: IconPosition.UPPER_LEFT, |
||||||
|
url: '', |
||||||
|
size: 40, |
||||||
|
metrics: [], |
||||||
|
conditions: [], |
||||||
|
values: [], |
||||||
|
} |
||||||
|
|
||||||
|
const fieldNamePickerSettings = { |
||||||
|
settings: { width: 24 }, |
||||||
|
} as any; |
||||||
|
|
||||||
|
export function IconsEditor({ onChange, value, context }: StandardEditorProps<Array<IconConfig>>) { |
||||||
|
const icons = value; |
||||||
|
|
||||||
|
const addIcon = () => { |
||||||
|
onChange( |
||||||
|
_.concat(icons, _.cloneDeep(DEFAULT_ICON)) |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const removeIcon = (idx: number) => { |
||||||
|
onChange( |
||||||
|
_.filter(icons, (icon, iconIdx) => iconIdx !== idx) |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const addCondition = (iconIdx:number) => { |
||||||
|
icons[iconIdx].conditions.push(Condition.GREATER_OR_EQUAL); |
||||||
|
icons[iconIdx].metrics.push(''); |
||||||
|
icons[iconIdx].values.push(0); |
||||||
|
|
||||||
|
onChange(icons); |
||||||
|
}; |
||||||
|
|
||||||
|
const removeCondition = (iconIdx: number, conditionIdx: number) => { |
||||||
|
icons[iconIdx].conditions.splice(conditionIdx, 1); |
||||||
|
icons[iconIdx].metrics.splice(conditionIdx, 1); |
||||||
|
icons[iconIdx].values.splice(conditionIdx, 1); |
||||||
|
|
||||||
|
onChange(icons); |
||||||
|
}; |
||||||
|
|
||||||
|
const onIconFieldChange = (iconIdx: number, field: keyof IconConfig, value: any) => { |
||||||
|
// @ts-ignore
|
||||||
|
icons[iconIdx][field] = value; |
||||||
|
|
||||||
|
onChange(icons); |
||||||
|
}; |
||||||
|
|
||||||
|
const onConditionChange = (iconIdx: number, conditionIdx: number, field: keyof IconConfig, value: any) => { |
||||||
|
console.log(value) |
||||||
|
// @ts-ignore
|
||||||
|
icons[iconIdx][field][conditionIdx] = value; |
||||||
|
|
||||||
|
onChange(icons); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<ThemeContext.Consumer> |
||||||
|
{(theme) => { |
||||||
|
const styles = getStyles(theme.v1); |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<div className={styles.icons}> |
||||||
|
{ |
||||||
|
icons.map((icon, iconIdx) => { |
||||||
|
return ( |
||||||
|
<div className={styles.icon}> |
||||||
|
<IconButton name="trash-alt" onClick={() => removeIcon(iconIdx)}></IconButton> |
||||||
|
<Input |
||||||
|
type="url" |
||||||
|
placeholder="Image URL" |
||||||
|
value={icon.url} |
||||||
|
onChange={evt => onIconFieldChange(iconIdx, 'url', (evt.target as any).value)} |
||||||
|
/> |
||||||
|
<RadioButtonGroup |
||||||
|
value={icon.position} |
||||||
|
options={positionOptions} |
||||||
|
onChange={newVal => onIconFieldChange(iconIdx, 'position', newVal)} |
||||||
|
/> |
||||||
|
<Slider |
||||||
|
value={icon.size} |
||||||
|
min={1} |
||||||
|
max={100} |
||||||
|
step={1} |
||||||
|
onAfterChange={newVal => onIconFieldChange(iconIdx, 'size', newVal)} |
||||||
|
/> |
||||||
|
|
||||||
|
{ |
||||||
|
icon.conditions.map((condition, conditionIdx) => { |
||||||
|
return ( |
||||||
|
<HorizontalGroup> |
||||||
|
<FieldNamePicker |
||||||
|
context={context} |
||||||
|
value={icon.metrics[conditionIdx]} |
||||||
|
onChange={(newVal: any) => onConditionChange(iconIdx, conditionIdx, 'metrics', newVal) } |
||||||
|
item={fieldNamePickerSettings} |
||||||
|
/> |
||||||
|
<RadioButtonGroup |
||||||
|
value={icon.conditions[conditionIdx]} |
||||||
|
options={conditionOptions} |
||||||
|
onChange={newVal => onConditionChange(iconIdx, conditionIdx, 'conditions', newVal)} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
placeholder="value" |
||||||
|
value={icon.values[conditionIdx]} |
||||||
|
onChange={evt => onConditionChange(iconIdx, conditionIdx, 'values', (evt.target as any).value)} |
||||||
|
/> |
||||||
|
<IconButton name="trash-alt" onClick={() => removeCondition(iconIdx, conditionIdx)}></IconButton> |
||||||
|
</HorizontalGroup> |
||||||
|
) |
||||||
|
}) |
||||||
|
} |
||||||
|
<Button variant="secondary" onClick={() => addCondition(iconIdx)}>Add Condition</Button> |
||||||
|
</div> |
||||||
|
) |
||||||
|
}) |
||||||
|
} |
||||||
|
</div> |
||||||
|
<Button variant="secondary" onClick={addIcon}>Add Icon</Button> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
</ThemeContext.Consumer> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
interface IconsEditorStyles { |
||||||
|
icons: string; |
||||||
|
icon: string; |
||||||
|
} |
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme): IconsEditorStyles => { |
||||||
|
return { |
||||||
|
icons: css` |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
margin-bottom: ${theme.spacing.formSpacingBase * 2}px; |
||||||
|
`,
|
||||||
|
icon: css` |
||||||
|
margin-bottom: ${theme.spacing.sm}; |
||||||
|
border-bottom: 1px solid ${theme.colors.panelBorder}; |
||||||
|
padding-bottom: ${theme.spacing.formSpacingBase * 2}px; |
||||||
|
|
||||||
|
&:last-child { |
||||||
|
border: none; |
||||||
|
margin-bottom: 0; |
||||||
|
} |
||||||
|
`,
|
||||||
|
} |
||||||
|
}); |
@ -0,0 +1,46 @@ |
|||||||
|
// copied from https://github.com/grafana/grafana/blob/3c6e0e8ef85048af952367751e478c08342e17b4/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx
|
||||||
|
import React, { useCallback } from 'react'; |
||||||
|
|
||||||
|
import { FieldNamePickerConfigSettings, SelectableValue, StandardEditorProps } from '@grafana/data'; |
||||||
|
|
||||||
|
import { Select } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { useFieldDisplayNames, useSelectOptions, frameHasName } from './utils'; |
||||||
|
|
||||||
|
// Pick a field name out of the fulds
|
||||||
|
export const FieldNamePicker: React.FC<StandardEditorProps<string, FieldNamePickerConfigSettings>> = ({ |
||||||
|
value, |
||||||
|
onChange, |
||||||
|
context, |
||||||
|
item, |
||||||
|
}) => { |
||||||
|
const settings: FieldNamePickerConfigSettings = item.settings ?? {}; |
||||||
|
const names = useFieldDisplayNames(context.data, settings?.filter); |
||||||
|
const selectOptions = useSelectOptions(names, value); |
||||||
|
|
||||||
|
const onSelectChange = useCallback( |
||||||
|
(selection?: SelectableValue<string>) => { |
||||||
|
if (selection && !frameHasName(selection.value, names)) { |
||||||
|
return; // can not select name that does not exist?
|
||||||
|
} |
||||||
|
return onChange(selection?.value); |
||||||
|
}, |
||||||
|
[names, onChange] |
||||||
|
); |
||||||
|
|
||||||
|
const selectedOption = selectOptions.find((v: any) => v.value === value); |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Select |
||||||
|
menuShouldPortal |
||||||
|
value={selectedOption} |
||||||
|
placeholder={settings.placeholderText ?? 'Select field'} |
||||||
|
options={selectOptions} |
||||||
|
onChange={onSelectChange} |
||||||
|
noOptionsMessage={settings.noFieldsMessage} |
||||||
|
width={settings.width} |
||||||
|
isClearable={true} |
||||||
|
/> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,204 @@ |
|||||||
|
// copied from https://github.com/grafana/grafana/blob/3c6e0e8ef85048af952367751e478c08342e17b4/packages/grafana-ui/src/components/MatchersUI/types.ts
|
||||||
|
import { Field, FieldType } from '@grafana/data'; |
||||||
|
|
||||||
|
export type ComponentSize = 'xs' | 'sm' | 'md' | 'lg'; |
||||||
|
export type IconType = 'mono' | 'default' | 'solid'; |
||||||
|
export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl'; |
||||||
|
|
||||||
|
export const getAvailableIcons = () => |
||||||
|
[ |
||||||
|
'anchor', |
||||||
|
'angle-double-down', |
||||||
|
'angle-double-right', |
||||||
|
'angle-double-up', |
||||||
|
'angle-down', |
||||||
|
'angle-left', |
||||||
|
'angle-right', |
||||||
|
'angle-up', |
||||||
|
'apps', |
||||||
|
'arrow', |
||||||
|
'arrow-down', |
||||||
|
'arrow-from-right', |
||||||
|
'arrow-left', |
||||||
|
'arrow-random', |
||||||
|
'arrow-right', |
||||||
|
'arrow-up', |
||||||
|
'arrows-h', |
||||||
|
'backward', |
||||||
|
'bars', |
||||||
|
'bell', |
||||||
|
'bell-slash', |
||||||
|
'bolt', |
||||||
|
'book', |
||||||
|
'bookmark', |
||||||
|
'book-open', |
||||||
|
'brackets-curly', |
||||||
|
'building', |
||||||
|
'bug', |
||||||
|
'building', |
||||||
|
'calculator-alt', |
||||||
|
'calendar-alt', |
||||||
|
'camera', |
||||||
|
'capture', |
||||||
|
'channel-add', |
||||||
|
'chart-line', |
||||||
|
'check', |
||||||
|
'check-circle', |
||||||
|
'circle', |
||||||
|
'clipboard-alt', |
||||||
|
'clock-nine', |
||||||
|
'cloud', |
||||||
|
'cloud-download', |
||||||
|
'cloud-upload', |
||||||
|
'code-branch', |
||||||
|
'cog', |
||||||
|
'columns', |
||||||
|
'comment-alt', |
||||||
|
'comment-alt-message', |
||||||
|
'comment-alt-share', |
||||||
|
'comments-alt', |
||||||
|
'compass', |
||||||
|
'copy', |
||||||
|
'credit-card', |
||||||
|
'cube', |
||||||
|
'dashboard', |
||||||
|
'database', |
||||||
|
'document-info', |
||||||
|
'download-alt', |
||||||
|
'draggabledots', |
||||||
|
'edit', |
||||||
|
'ellipsis-v', |
||||||
|
'envelope', |
||||||
|
'exchange-alt', |
||||||
|
'exclamation-triangle', |
||||||
|
'exclamation-circle', |
||||||
|
'external-link-alt', |
||||||
|
'eye', |
||||||
|
'eye-slash', |
||||||
|
'ellipsis-h', |
||||||
|
'fa fa-spinner', |
||||||
|
'favorite', |
||||||
|
'file-alt', |
||||||
|
'file-blank', |
||||||
|
'file-copy-alt', |
||||||
|
'filter', |
||||||
|
'folder', |
||||||
|
'font', |
||||||
|
'fire', |
||||||
|
'folder-open', |
||||||
|
'folder-plus', |
||||||
|
'folder-upload', |
||||||
|
'forward', |
||||||
|
'gf-bar-alignment-after', |
||||||
|
'gf-bar-alignment-before', |
||||||
|
'gf-bar-alignment-center', |
||||||
|
'gf-grid', |
||||||
|
'gf-interpolation-linear', |
||||||
|
'gf-interpolation-smooth', |
||||||
|
'gf-interpolation-step-after', |
||||||
|
'gf-interpolation-step-before', |
||||||
|
'gf-landscape', |
||||||
|
'gf-layout-simple', |
||||||
|
'gf-logs', |
||||||
|
'gf-portrait', |
||||||
|
'grafana', |
||||||
|
'graph-bar', |
||||||
|
'heart', |
||||||
|
'heart-break', |
||||||
|
'history', |
||||||
|
'home-alt', |
||||||
|
'hourglass', |
||||||
|
'import', |
||||||
|
'info', |
||||||
|
'info-circle', |
||||||
|
'key-skeleton-alt', |
||||||
|
'keyboard', |
||||||
|
'layer-group', |
||||||
|
'library-panel', |
||||||
|
'line-alt', |
||||||
|
'link', |
||||||
|
'list-ui-alt', |
||||||
|
'list-ul', |
||||||
|
'lock', |
||||||
|
'map-marker', |
||||||
|
'message', |
||||||
|
'minus', |
||||||
|
'minus-circle', |
||||||
|
'mobile-android', |
||||||
|
'monitor', |
||||||
|
'palette', |
||||||
|
'panel-add', |
||||||
|
'pause', |
||||||
|
'pen', |
||||||
|
'percentage', |
||||||
|
'play', |
||||||
|
'plug', |
||||||
|
'plus', |
||||||
|
'plus-circle', |
||||||
|
'plus-square', |
||||||
|
'power', |
||||||
|
'presentation-play', |
||||||
|
'process', |
||||||
|
'question-circle', |
||||||
|
'record-audio', |
||||||
|
'repeat', |
||||||
|
'rocket', |
||||||
|
'save', |
||||||
|
'search', |
||||||
|
'search-minus', |
||||||
|
'search-plus', |
||||||
|
'share-alt', |
||||||
|
'shield', |
||||||
|
'shield-exclamation', |
||||||
|
'signal', |
||||||
|
'signin', |
||||||
|
'signout', |
||||||
|
'sitemap', |
||||||
|
'slack', |
||||||
|
'sliders-v-alt', |
||||||
|
'sort-amount-down', |
||||||
|
'sort-amount-up', |
||||||
|
'square-shape', |
||||||
|
'star', |
||||||
|
'step-backward', |
||||||
|
'sync', |
||||||
|
'table', |
||||||
|
'tag-alt', |
||||||
|
'text-fields', |
||||||
|
'times', |
||||||
|
'toggle-on', |
||||||
|
'trash-alt', |
||||||
|
'unlock', |
||||||
|
'upload', |
||||||
|
'user', |
||||||
|
'users-alt', |
||||||
|
'wrap-text', |
||||||
|
'x', |
||||||
|
] as const; |
||||||
|
|
||||||
|
type BrandIconNames = 'google' | 'microsoft' | 'github' | 'gitlab' | 'okta'; |
||||||
|
|
||||||
|
export type IconName = ReturnType<typeof getAvailableIcons>[number] | BrandIconNames; |
||||||
|
|
||||||
|
/** Get the icon for a given field type */ |
||||||
|
export function getFieldTypeIcon(field?: Field): IconName { |
||||||
|
if (field) { |
||||||
|
switch (field.type) { |
||||||
|
case FieldType.time: |
||||||
|
return 'clock-nine'; |
||||||
|
case FieldType.string: |
||||||
|
return 'font'; |
||||||
|
case FieldType.number: |
||||||
|
return 'calculator-alt'; |
||||||
|
case FieldType.boolean: |
||||||
|
return 'toggle-on'; |
||||||
|
case FieldType.trace: |
||||||
|
return 'info-circle'; |
||||||
|
case FieldType.geo: |
||||||
|
return 'map-marker'; |
||||||
|
case FieldType.other: |
||||||
|
return 'brackets-curly'; |
||||||
|
} |
||||||
|
} |
||||||
|
return 'question-circle'; |
||||||
|
} |
@ -0,0 +1,116 @@ |
|||||||
|
// copied from https://github.com/grafana/grafana/blob/3c6e0e8ef85048af952367751e478c08342e17b4/packages/grafana-ui/src/components/MatchersUI/utils.ts
|
||||||
|
import { useMemo } from 'react'; |
||||||
|
|
||||||
|
import { DataFrame, Field, getFieldDisplayName, SelectableValue } from '@grafana/data'; |
||||||
|
|
||||||
|
import { getFieldTypeIcon } from './types'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @internal |
||||||
|
*/ |
||||||
|
export interface FrameFieldsDisplayNames { |
||||||
|
// The display names
|
||||||
|
display: Set<string>; |
||||||
|
|
||||||
|
// raw field names (that are explicitly not visible)
|
||||||
|
raw: Set<string>; |
||||||
|
|
||||||
|
// Field mappings (duplicates are not supported)
|
||||||
|
fields: Map<string, Field>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @internal |
||||||
|
*/ |
||||||
|
export function frameHasName(name: string | undefined, names: FrameFieldsDisplayNames) { |
||||||
|
if (!name) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
return names.display.has(name) || names.raw.has(name); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Retuns the distinct names in a set of frames |
||||||
|
*/ |
||||||
|
function getFrameFieldsDisplayNames(data: DataFrame[], filter?: (field: Field) => boolean): FrameFieldsDisplayNames { |
||||||
|
const names: FrameFieldsDisplayNames = { |
||||||
|
display: new Set<string>(), |
||||||
|
raw: new Set<string>(), |
||||||
|
fields: new Map<string, Field>(), |
||||||
|
}; |
||||||
|
|
||||||
|
for (const frame of data) { |
||||||
|
for (const field of frame.fields) { |
||||||
|
if (filter && !filter(field)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
const disp = getFieldDisplayName(field, frame, data); |
||||||
|
names.display.add(disp); |
||||||
|
names.fields.set(disp, field); |
||||||
|
if (field.name && disp !== field.name) { |
||||||
|
names.raw.add(field.name); |
||||||
|
names.fields.set(field.name, field); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return names; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @internal |
||||||
|
*/ |
||||||
|
export function useFieldDisplayNames(data: DataFrame[], filter?: (field: Field) => boolean): FrameFieldsDisplayNames { |
||||||
|
return useMemo(() => { |
||||||
|
return getFrameFieldsDisplayNames(data, filter); |
||||||
|
}, [data, filter]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @internal |
||||||
|
*/ |
||||||
|
export function useSelectOptions( |
||||||
|
displayNames: FrameFieldsDisplayNames, |
||||||
|
currentName?: string, |
||||||
|
firstItem?: SelectableValue<string>, |
||||||
|
fieldType?: string |
||||||
|
): Array<SelectableValue<string>> { |
||||||
|
return useMemo(() => { |
||||||
|
let found = false; |
||||||
|
const options: Array<SelectableValue<string>> = []; |
||||||
|
if (firstItem) { |
||||||
|
options.push(firstItem); |
||||||
|
} |
||||||
|
for (const name of displayNames.display) { |
||||||
|
if (!found && name === currentName) { |
||||||
|
found = true; |
||||||
|
} |
||||||
|
const field = displayNames.fields.get(name); |
||||||
|
if (!fieldType || fieldType === field?.type) { |
||||||
|
options.push({ |
||||||
|
value: name, |
||||||
|
label: name, |
||||||
|
icon: field ? getFieldTypeIcon(field) : undefined, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
for (const name of displayNames.raw) { |
||||||
|
if (!displayNames.display.has(name)) { |
||||||
|
if (!found && name === currentName) { |
||||||
|
found = true; |
||||||
|
} |
||||||
|
options.push({ |
||||||
|
value: name, |
||||||
|
label: `${name} (base field name)`, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (currentName && !found) { |
||||||
|
options.push({ |
||||||
|
value: currentName, |
||||||
|
label: `${currentName} (not found)`, |
||||||
|
}); |
||||||
|
} |
||||||
|
return options; |
||||||
|
}, [displayNames, currentName, firstItem, fieldType]); |
||||||
|
} |
Loading…
Reference in new issue