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