Canvas Panel: icon/image select modal (#38844)

* feature: add icon modal

* fix: make apply send on change

* move to resource folder

* allow empty

* type

* icon modal

* fix: edit styles

* fix: edit styles

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
An
2021-09-07 13:46:20 -04:00
committed by GitHub
parent 51776e6bd3
commit b14b267bed
17 changed files with 382 additions and 46 deletions

View File

@ -12,19 +12,24 @@ export const StringValueEditor: React.FC<StandardEditorProps<string, StringField
const onValueChange = useCallback(
(e: React.SyntheticEvent) => {
let nextValue = value ?? '';
if (e.hasOwnProperty('key')) {
// handling keyboard event
const evt = e as React.KeyboardEvent<HTMLInputElement>;
if (evt.key === 'Enter' && !item.settings?.useTextarea) {
onChange(evt.currentTarget.value.trim() === '' ? undefined : evt.currentTarget.value);
nextValue = evt.currentTarget.value.trim();
}
} else {
// handling form event
const evt = e as React.FormEvent<HTMLInputElement>;
onChange(evt.currentTarget.value.trim() === '' ? undefined : evt.currentTarget.value);
nextValue = evt.currentTarget.value.trim();
}
if (nextValue === value) {
return; // no change
}
onChange(nextValue === '' ? undefined : nextValue);
},
[item.settings?.useTextarea, onChange]
[value, item.settings?.useTextarea, onChange]
);
return (

View File

@ -1,7 +1,12 @@
import React, { CSSProperties } from 'react';
import { CanvasElementItem, CanvasElementProps } from '../element';
import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from 'app/features/dimensions';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
ResourceDimensionMode,
getPublicOrAbsoluteUrl,
} from 'app/features/dimensions';
import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors';
import SVG from 'react-inlinesvg';
import { css } from '@emotion/css';
@ -62,7 +67,7 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
defaultConfig: {
path: {
mode: ResourceDimensionMode.Fixed,
fixed: 'question-circle.svg',
fixed: 'img/icons/unicons/question-circle.svg',
},
fill: { fixed: '#FFF899' },
},
@ -74,17 +79,12 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
// Called when data changes
prepareData: (ctx: DimensionContext, cfg: IconConfig) => {
const iconRoot = (window as any).__grafana_public_path__ + 'img/icons/unicons/';
let path: string | undefined = undefined;
if (cfg.path) {
path = ctx.getResource(cfg.path).value();
}
if (!path || !isString(path)) {
// must be something?
path = 'question-circle.svg';
}
if (path.indexOf(':/') < 0) {
path = iconRoot + path;
path = getPublicOrAbsoluteUrl('img/icons/unicons/question-circle.svg');
}
const data: IconData = {

View File

@ -0,0 +1,110 @@
import React, { memo, CSSProperties } from 'react';
import { areEqual, FixedSizeGrid as Grid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { useTheme2, stylesFactory } from '@grafana/ui';
import SVG from 'react-inlinesvg';
import { css } from '@emotion/css';
interface CellProps {
columnIndex: number;
rowIndex: number;
style: CSSProperties;
data: any;
}
function Cell(props: CellProps) {
const { columnIndex, rowIndex, style, data } = props;
const { cards, columnCount, onChange, folder } = data;
const singleColumnIndex = columnIndex + rowIndex * columnCount;
const card = cards[singleColumnIndex];
const theme = useTheme2();
const styles = getStyles(theme);
return (
<div style={style}>
{card && (
<div key={card.value} className={styles.card} onClick={() => onChange(`${folder.value}/${card.value}`)}>
{folder.value.includes('icons') ? (
<SVG src={card.imgUrl} className={styles.img} />
) : (
<img src={card.imgUrl} className={styles.img} />
)}
<h6 className={styles.text}>{card.label.substr(0, card.label.length - 4)}</h6>
</div>
)}
</div>
);
}
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
card: css`
display: inline-block;
width: 80px;
height: 80px;
margin: 0.75rem;
text-align: center;
cursor: pointer;
position: relative;
background-color: transparent;
border: 1px solid transparent;
border-radius: 8px;
padding-top: 6px;
:hover {
border-color: ${theme.colors.action.hover};
box-shadow: ${theme.shadows.z2};
}
`,
img: css`
width: 50px;
height: 50px;
object-fit: cover;
vertical-align: middle;
fill: ${theme.colors.text.primary};
`,
text: css`
color: ${theme.colors.text.primary};
white-space: nowrap;
font-size: 12px;
text-overflow: ellipsis;
display: block;
overflow: hidden;
`,
};
});
interface CardProps {
onChange: (value: string) => void;
cards: SelectableValue[];
currentFolder: SelectableValue<string> | undefined;
}
export const ResourceCards = (props: CardProps) => {
const { onChange, cards, currentFolder: folder } = props;
return (
<AutoSizer defaultWidth={1920} defaultHeight={1080}>
{({ width, height }) => {
const cardWidth = 80;
const cardHeight = 80;
const columnCount = Math.floor(width / cardWidth);
const rowCount = Math.ceil(cards.length / columnCount);
return (
<Grid
width={width}
height={height}
columnCount={columnCount}
columnWidth={cardWidth}
rowCount={rowCount}
rowHeight={cardHeight}
itemData={{ cards, columnCount, onChange, folder }}
>
{memo(Cell, areEqual)}
</Grid>
);
}}
</AutoSizer>
);
};

View File

@ -1,14 +1,9 @@
import React, { FC, useCallback } from 'react';
import {
FieldNamePickerConfigSettings,
StandardEditorProps,
StandardEditorsRegistryItem,
StringFieldConfigSettings,
} from '@grafana/data';
import React, { FC, useCallback, useState } from 'react';
import { FieldNamePickerConfigSettings, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
import { ResourceDimensionConfig, ResourceDimensionMode, ResourceDimensionOptions } from '../types';
import { InlineField, InlineFieldRow, RadioButtonGroup, StringValueEditor } from '@grafana/ui';
import { InlineField, InlineFieldRow, RadioButtonGroup, Button, Modal, Input } from '@grafana/ui';
import { FieldNamePicker } from '../../../../../packages/grafana-ui/src/components/MatchersUI/FieldNamePicker';
import IconSelector from './IconSelector';
import { ResourcePicker } from './ResourcePicker';
const resourceOptions = [
{ label: 'Fixed', value: ResourceDimensionMode.Fixed, description: 'Fixed value' },
@ -20,18 +15,12 @@ const dummyFieldSettings: StandardEditorsRegistryItem<string, FieldNamePickerCon
settings: {},
} as any;
const dummyImageStringSettings: StandardEditorsRegistryItem<string, StringFieldConfigSettings> = {
settings: {
placeholder: 'Enter image URL',
},
} as any;
export const ResourceDimensionEditor: FC<
StandardEditorProps<ResourceDimensionConfig, ResourceDimensionOptions, any>
> = (props) => {
const { value, context, onChange, item } = props;
const resourceType = item.settings?.resourceType ?? 'icon';
const labelWidth = 9;
const [isOpen, setOpen] = useState(false);
const onModeChange = useCallback(
(mode) => {
@ -54,19 +43,31 @@ export const ResourceDimensionEditor: FC<
);
const onFixedChange = useCallback(
(fixed) => {
(fixed?: string) => {
onChange({
...value,
fixed,
fixed: fixed ?? '',
});
setOpen(false);
},
[onChange, value]
);
const openModal = useCallback(() => {
setOpen(true);
}, []);
const mode = value?.mode ?? ResourceDimensionMode.Fixed;
const mediaType = item.settings?.resourceType ?? 'icon';
return (
<>
{isOpen && (
<Modal isOpen={isOpen} title={`Select ${mediaType}`} onDismiss={() => setOpen(false)} closeOnEscape>
<ResourcePicker onChange={onFixedChange} value={value?.fixed} mediaType={mediaType} />
</Modal>
)}
<InlineFieldRow>
<InlineField label="Source" labelWidth={labelWidth} grow={true}>
<RadioButtonGroup value={mode} options={resourceOptions} onChange={onModeChange} fullWidth />
@ -86,21 +87,10 @@ export const ResourceDimensionEditor: FC<
)}
{mode === ResourceDimensionMode.Fixed && (
<InlineFieldRow>
{resourceType === 'icon' && (
<InlineField label="Icon" labelWidth={labelWidth} grow={true}>
<IconSelector value={value?.fixed} onChange={onFixedChange} />
</InlineField>
)}
{resourceType === 'image' && (
<InlineField label="Image" labelWidth={labelWidth} grow={true}>
<StringValueEditor
context={context}
value={value?.fixed}
onChange={onFixedChange}
item={dummyImageStringSettings}
/>
</InlineField>
)}
<InlineField label={null} grow>
<Input value={value?.fixed} placeholder="Resource URL" readOnly={true} onClick={openModal} />
</InlineField>
<Button icon="folder-open" variant="secondary" onClick={openModal} />
</InlineFieldRow>
)}
{mode === ResourceDimensionMode.Mapping && (

View File

@ -0,0 +1,154 @@
import React, { useEffect, useState, ChangeEvent } from 'react';
import {
TabContent,
Button,
Select,
Input,
Spinner,
TabsBar,
Tab,
StringValueEditor,
useTheme2,
stylesFactory,
} from '@grafana/ui';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { ResourceCards } from './ResourceCards';
import SVG from 'react-inlinesvg';
import { css } from '@emotion/css';
import { getPublicOrAbsoluteUrl } from '../resource';
interface Props {
value?: string; //img/icons/unicons/0-plus.svg
onChange: (value?: string) => void;
mediaType: 'icon' | 'image';
}
export function ResourcePicker(props: Props) {
const { value, onChange, mediaType } = props;
const folders = (mediaType === 'icon' ? ['img/icons/unicons', 'img/icons/iot'] : ['img/bg']).map((v) => ({
label: v,
value: v,
}));
const folderOfCurrentValue = value ? folders.filter((folder) => value.indexOf(folder.value) > -1)[0] : folders[0];
const [currentFolder, setCurrentFolder] = useState<SelectableValue<string>>(folderOfCurrentValue);
const [tabs, setTabs] = useState([
{ label: 'Select', active: true },
// { label: 'Upload', active: false },
]);
const [directoryIndex, setDirectoryIndex] = useState<SelectableValue[]>([]);
const [defaultList, setDefaultList] = useState<SelectableValue[]>([]);
const theme = useTheme2();
const styles = getStyles(theme);
useEffect(() => {
// we don't want to load everything before picking a folder
if (currentFolder) {
getBackendSrv()
.get(`public/${currentFolder?.value}/index.json`)
.then((data) => {
const cards = data.files.map((v: string) => ({
value: v,
label: v,
imgUrl: `public/${currentFolder?.value}/${v}`,
}));
setDirectoryIndex(cards);
setDefaultList(cards);
})
.catch((e) => console.error(e));
} else {
return;
}
}, [currentFolder]);
const onChangeSearch = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.value) {
const filtered = directoryIndex.filter((card) =>
card.value
// exclude file type (.svg) in the search
.substr(0, card.value.length - 4)
.toLocaleLowerCase()
.includes(e.target.value.toLocaleLowerCase())
);
setDirectoryIndex(filtered);
} else {
setDirectoryIndex(defaultList);
}
};
const imgSrc = getPublicOrAbsoluteUrl(value!);
return (
<div>
<div className={styles.currentItem}>
{value && (
<>
{mediaType === 'icon' && <SVG src={imgSrc} className={styles.img} />}
{mediaType === 'image' && <img src={imgSrc} className={styles.img} />}
</>
)}
<StringValueEditor value={value ?? ''} onChange={onChange} item={{} as any} context={{} as any} />
<Button variant="secondary" onClick={() => onChange(value)}>
Apply
</Button>
</div>
<TabsBar>
{tabs.map((tab, index) => (
<Tab
label={tab.label}
key={index}
active={tab.active}
onChangeTab={() => setTabs(tabs.map((tab, idx) => ({ ...tab, active: idx === index })))}
/>
))}
</TabsBar>
<TabContent>
{tabs[0].active && (
<div className={styles.tabContent}>
<Select options={folders} onChange={setCurrentFolder} value={currentFolder} />
<Input placeholder="Search" onChange={onChangeSearch} />
{directoryIndex ? (
<div className={styles.cardsWrapper}>
<ResourceCards cards={directoryIndex} onChange={onChange} currentFolder={currentFolder} />
</div>
) : (
<Spinner />
)}
</div>
)}
{/* TODO: add file upload
{tabs[1].active && (
<FileUpload
onFileUpload={({ currentTarget }) => console.log('file', currentTarget?.files && currentTarget.files[0])}
className={styles.tabContent}
/>
)} */}
</TabContent>
</div>
);
}
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
cardsWrapper: css`
height: calc(100vh - 480px);
`,
tabContent: css`
margin-top: 20px;
& > :nth-child(2) {
margin-top: 10px;
},
`,
currentItem: css`
display: flex;
justify-content: space-between;
align-items: center;
column-gap: 2px;
margin: -18px 0px 18px 0px;
`,
img: css`
width: 40px;
height: 40px;
fill: ${theme.colors.text.primary};
`,
};
});

View File

@ -5,6 +5,12 @@ import { findField, getLastNotNullFieldValue } from './utils';
//---------------------------------------------------------
// Resource dimension
//---------------------------------------------------------
export function getPublicOrAbsoluteUrl(v: string): string {
if (!v) {
return '';
}
return v.indexOf(':/') > 0 ? v : (window as any).__grafana_public_path__ + v;
}
export function getResourceDimension(
frame: DataFrame | undefined,
@ -12,7 +18,7 @@ export function getResourceDimension(
): DimensionSupplier<string> {
const mode = config.mode ?? ResourceDimensionMode.Fixed;
if (mode === ResourceDimensionMode.Fixed) {
const v = config.fixed!;
const v = getPublicOrAbsoluteUrl(config.fixed!);
return {
isAssumed: !Boolean(v),
fixed: v,
@ -33,7 +39,7 @@ export function getResourceDimension(
}
if (mode === ResourceDimensionMode.Mapping) {
const mapper = (v: any) => `${v}`;
const mapper = (v: any) => getPublicOrAbsoluteUrl(`${v}`);
return {
field,
get: (i) => mapper(field.values.get(i)),

4
public/img/bg/index.json Normal file
View File

@ -0,0 +1,4 @@
{
"name": "bg",
"files": ["p0.png", "p1.png", "p2.png", "p3.png", "p4.png", "p5.png", "p6.png"]
}

BIN
public/img/bg/p0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
public/img/bg/p1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

BIN
public/img/bg/p2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

BIN
public/img/bg/p3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
public/img/bg/p4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

BIN
public/img/bg/p5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

BIN
public/img/bg/p6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

View File

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 512 512" enableBackground="new 0 0 512 512">
<g>
<g>
<path d="M256,168.751v15.673c38.891,0,70.531,31.64,70.531,70.531h15.673C342.204,207.421,303.532,168.751,256,168.751z" />
</g>
<g>
<path
d="M261.881,302.02L256,295.344l-5.881,6.676c-6.567,7.455-64.164,73.887-64.164,110.156
c0,38.623,31.422,70.045,70.045,70.045s70.045-31.422,70.045-70.045C326.045,375.908,268.448,309.475,261.881,302.02z
M256,466.546c-29.98,0-54.371-24.391-54.371-54.371c0-23.103,35.055-69.819,54.35-92.901c5.88,7.064,15.39,18.868,24.808,32.169
c19.077,26.943,29.584,48.511,29.584,60.732C310.371,442.155,285.98,466.546,256,466.546z"
/>
</g>
<g>
<path d="M232.732,412.734h-15.674c0,21.472,17.469,38.941,38.941,38.941v-15.673C243.171,436.002,232.732,425.563,232.732,412.734 z" />
</g>
<g>
<path
d="M376.97,187.558v0.001c-19.753-35.44-53.43-59.909-92.235-68.065V76.799h62.694V58.514
c0-15.845-12.891-28.735-28.735-28.735H193.306c-15.845,0-28.735,12.89-28.735,28.735v18.286h62.694v42.695
c-38.804,8.155-72.481,32.626-92.234,68.065H0v130.612h132.803c11.6,22.604,29.109,41.501,50.889,54.86l8.195-13.36
c-20.593-12.631-36.881-30.841-47.104-52.661l-2.114-4.512H47.02v-99.265h97.489l2.186-4.257
c21.157-41.202,63.04-66.797,109.305-66.797c46.264,0,88.148,25.595,109.305,66.796l2.186,4.257h97.489v99.265h-95.648
l-2.114,4.512c-10.222,21.82-26.51,40.03-47.104,52.661l8.196,13.36c21.779-13.359,39.288-32.257,50.888-54.86H512V187.558H376.97
z M31.347,302.497H15.673v-99.265h15.673V302.497z M180.245,61.126v-2.612c0-7.202,5.859-13.061,13.061-13.061h125.388
c7.202,0,13.061,5.859,13.061,13.061v2.612h-47.02h-57.469H180.245z M269.061,117.126c-4.312-0.402-8.667-0.621-13.061-0.621
c-4.394,0-8.749,0.218-13.061,0.621V76.799h26.122V117.126z M496.327,302.497h-15.673v-99.265h15.673V302.497z"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,4 @@
{
"name": "line",
"files": ["faucet.svg", "pump.svg"]
}

View File

@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 512 512" enableBackground="new 0 0 512 512">
<g>
<path
d="M493.137,59.284c10.401,0,18.863-8.463,18.863-18.863V18.863C512,8.463,503.537,0,493.137,0H374.568
c-10.401,0-18.863,8.463-18.863,18.863v21.558c0,10.401,8.463,18.863,18.863,18.863h2.695v48.505h-7.211
c-3.032-6.368-9.532-10.779-17.042-10.779h-21.558c-10.401,0-18.863,8.463-18.863,18.863v2.695h-13.474
c-10.401,0-18.863,8.463-18.863,18.863v10.779c0,10.401,8.463,18.863,18.863,18.863h13.474v16.168h-48.505v-13.474
c0-16.345-13.297-29.642-29.642-29.642h-204.8C13.297,140.126,0,153.423,0,169.768v172.463c0,16.345,13.297,29.642,29.642,29.642
h204.8c16.345,0,29.642-13.297,29.642-29.642v-13.474h48.505v16.168h-13.474c-10.401,0-18.863,8.463-18.863,18.863v10.779
c0,10.401,8.463,18.863,18.863,18.863h13.474v2.695c0,10.401,8.463,18.863,18.863,18.863h21.558
c7.51,0,14.009-4.411,17.042-10.779h7.211v48.505h-2.695c-10.401,0-18.863,8.463-18.863,18.863v21.558
c0,10.401,8.463,18.863,18.863,18.863h118.568c10.401,0,18.863-8.463,18.863-18.863v-21.558c0-10.401-8.463-18.863-18.863-18.863
h-2.695v-48.505h2.695c10.401,0,18.863-8.463,18.863-18.863V126.653c0-10.401-8.463-18.863-18.863-18.863h-2.695V59.284H493.137z
M247.916,342.232c0,7.43-6.044,13.474-13.474,13.474H59.284v-26.947h188.632V342.232z M247.916,312.589H59.284v-26.947h188.632
V312.589z M247.916,269.474H59.284v-26.947h188.632V269.474z M247.916,226.358H59.284v-2.695c0-4.465-3.62-8.084-8.084-8.084
s-8.084,3.62-8.084,8.084v132.042H29.642c-7.43,0-13.474-6.044-13.474-13.474V169.768c0-7.43,6.044-13.474,13.474-13.474h13.474
v35.032c0,4.465,3.62,8.084,8.084,8.084h196.716V226.358z M247.916,183.242H59.284v-26.947h175.158
c7.43,0,13.474,6.044,13.474,13.474V183.242z M312.589,377.263h-13.474c-1.485,0-2.695-1.209-2.695-2.695v-10.779
c0-1.485,1.209-2.695,2.695-2.695h13.474V377.263z M312.589,312.589h-48.505V199.411h48.505V312.589z M312.589,150.905h-13.474
c-1.485,0-2.695-1.209-2.695-2.695v-10.779c0-1.485,1.209-2.695,2.695-2.695h13.474V150.905z M493.137,468.884
c1.485,0,2.695,1.209,2.695,2.695v21.558c0,1.485-1.209,2.695-2.695,2.695H374.568c-1.485,0-2.695-1.209-2.695-2.695v-21.558
c0-1.485,1.209-2.695,2.695-2.695h32.337c4.465,0,8.084-3.62,8.084-8.084s-3.62-8.084-8.084-8.084h-13.474v-48.505h80.842v48.505
h-35.032c-4.465,0-8.084,3.62-8.084,8.084s3.62,8.084,8.084,8.084H493.137z M493.137,123.958c1.485,0,2.695,1.209,2.695,2.695
v258.695c0,1.485-1.209,2.695-2.695,2.695H371.874V202.105c0-4.465-3.62-8.084-8.084-8.084c-4.465,0-8.084,3.62-8.084,8.084
v194.021c0,1.485-1.209,2.695-2.695,2.695h-21.558c-1.485,0-2.695-1.209-2.695-2.695V115.874c0-1.485,1.209-2.695,2.695-2.695
h21.558c1.485,0,2.695,1.209,2.695,2.695v53.895c0,4.465,3.62,8.084,8.084,8.084c4.465,0,8.084-3.62,8.084-8.084v-45.811H493.137z
M439.242,59.284h35.032v48.505h-80.842V59.284h13.474c4.465,0,8.084-3.62,8.084-8.084s-3.62-8.084-8.084-8.084h-32.337
c-1.485,0-2.695-1.209-2.695-2.695V18.863c0-1.485,1.209-2.695,2.695-2.695h118.568c1.485,0,2.695,1.209,2.695,2.695v21.558
c0,1.485-1.209,2.695-2.695,2.695h-53.895c-4.465,0-8.084,3.62-8.084,8.084S434.777,59.284,439.242,59.284z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB