mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Resource picker/improve ux (#44402)
This commit is contained in:
parent
58ee553634
commit
8a7b469679
129
public/app/features/dimensions/editors/FolderPickerTab.tsx
Normal file
129
public/app/features/dimensions/editors/FolderPickerTab.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { Field, FilterInput, Select, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { MediaType, ResourceFolderName } from '../types';
|
||||
import { FileElement, GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { ResourceCards } from './ResourceCards';
|
||||
|
||||
const getFolders = (mediaType: MediaType) => {
|
||||
if (mediaType === MediaType.Icon) {
|
||||
return [ResourceFolderName.Icon, ResourceFolderName.IOT, ResourceFolderName.Marker];
|
||||
} else {
|
||||
return [ResourceFolderName.BG];
|
||||
}
|
||||
};
|
||||
|
||||
const getFolderIfExists = (folders: Array<SelectableValue<string>>, path: string) => {
|
||||
return folders.find((folder) => path.startsWith(folder.value!)) ?? folders[0];
|
||||
};
|
||||
|
||||
export interface ResourceItem {
|
||||
label: string;
|
||||
value: string; // includes folder
|
||||
search: string;
|
||||
imgUrl: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
mediaType: MediaType;
|
||||
folderName: ResourceFolderName;
|
||||
newValue: string;
|
||||
setNewValue: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const FolderPickerTab = (props: Props) => {
|
||||
const { value, mediaType, folderName, newValue, setNewValue } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const folders = getFolders(mediaType).map((v) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
}));
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>();
|
||||
|
||||
const [currentFolder, setCurrentFolder] = useState<SelectableValue<string>>(
|
||||
getFolderIfExists(folders, value?.length ? value : folderName)
|
||||
);
|
||||
const [directoryIndex, setDirectoryIndex] = useState<ResourceItem[]>([]);
|
||||
const [filteredIndex, setFilteredIndex] = useState<ResourceItem[]>([]);
|
||||
|
||||
const onChangeSearch = (query: string) => {
|
||||
if (query) {
|
||||
query = query.toLowerCase();
|
||||
setFilteredIndex(directoryIndex.filter((card) => card.search.includes(query)));
|
||||
} else {
|
||||
setFilteredIndex(directoryIndex);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// we don't want to load everything before picking a folder
|
||||
const folder = currentFolder?.value;
|
||||
if (folder) {
|
||||
const filter =
|
||||
mediaType === MediaType.Icon
|
||||
? (item: FileElement) => item.name.endsWith('.svg')
|
||||
: (item: FileElement) => item.name.endsWith('.png') || item.name.endsWith('.gif');
|
||||
|
||||
getDatasourceSrv()
|
||||
.get('-- Grafana --')
|
||||
.then((ds) => {
|
||||
(ds as GrafanaDatasource).listFiles(folder).subscribe({
|
||||
next: (frame) => {
|
||||
const cards: ResourceItem[] = [];
|
||||
frame.forEach((item) => {
|
||||
if (filter(item)) {
|
||||
const idx = item.name.lastIndexOf('.');
|
||||
cards.push({
|
||||
value: `${folder}/${item.name}`,
|
||||
label: item.name,
|
||||
search: (idx ? item.name.substr(0, idx) : item.name).toLowerCase(),
|
||||
imgUrl: `public/${folder}/${item.name}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
setDirectoryIndex(cards);
|
||||
setFilteredIndex(cards);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [mediaType, currentFolder]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<Select options={folders} onChange={setCurrentFolder} value={currentFolder} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FilterInput
|
||||
value={searchQuery ?? ''}
|
||||
placeholder="Search"
|
||||
onChange={(v) => {
|
||||
onChangeSearch(v);
|
||||
setSearchQuery(v);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{filteredIndex && (
|
||||
<div className={styles.cardsWrapper}>
|
||||
<ResourceCards cards={filteredIndex} onChange={(v) => setNewValue(v)} value={newValue} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
cardsWrapper: css`
|
||||
height: 30vh;
|
||||
min-height: 50px;
|
||||
margin-top: 5px;
|
||||
max-width: 680px;
|
||||
`,
|
||||
});
|
@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useTheme2, stylesFactory } from '@grafana/ui';
|
||||
import SVG from 'react-inlinesvg';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { ResourceItem } from './ResourcePicker';
|
||||
import { ResourceItem } from './FolderPickerTab';
|
||||
|
||||
interface CellProps {
|
||||
columnIndex: number;
|
||||
|
@ -1,18 +1,11 @@
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import {
|
||||
FieldNamePickerConfigSettings,
|
||||
GrafanaTheme2,
|
||||
StandardEditorProps,
|
||||
StandardEditorsRegistryItem,
|
||||
} from '@grafana/data';
|
||||
import { InlineField, InlineFieldRow, RadioButtonGroup, Button, Modal, Input, useStyles2 } from '@grafana/ui';
|
||||
import SVG from 'react-inlinesvg';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { FieldNamePickerConfigSettings, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
|
||||
import { InlineField, InlineFieldRow, RadioButtonGroup } from '@grafana/ui';
|
||||
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
|
||||
|
||||
import { ResourceDimensionConfig, ResourceDimensionMode, ResourceDimensionOptions } from '../types';
|
||||
import { FieldNamePicker } from '../../../../../packages/grafana-ui/src/components/MatchersUI/FieldNamePicker';
|
||||
import { ResourcePicker } from './ResourcePicker';
|
||||
import { MediaType, ResourceDimensionConfig, ResourceDimensionMode, ResourceDimensionOptions } from '../types';
|
||||
import { getPublicOrAbsoluteUrl, ResourceFolderName } from '..';
|
||||
import { ResourcePicker } from './ResourcePicker';
|
||||
|
||||
const resourceOptions = [
|
||||
{ label: 'Fixed', value: ResourceDimensionMode.Fixed, description: 'Fixed value' },
|
||||
@ -29,8 +22,6 @@ export const ResourceDimensionEditor: FC<
|
||||
> = (props) => {
|
||||
const { value, context, onChange, item } = props;
|
||||
const labelWidth = 9;
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onModeChange = useCallback(
|
||||
(mode) => {
|
||||
@ -58,7 +49,6 @@ export const ResourceDimensionEditor: FC<
|
||||
...value,
|
||||
fixed: fixed ?? '',
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
@ -68,16 +58,12 @@ export const ResourceDimensionEditor: FC<
|
||||
onChange({ mode: ResourceDimensionMode.Fixed, fixed: '', field: '' });
|
||||
};
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const mode = value?.mode ?? ResourceDimensionMode.Fixed;
|
||||
const showSourceRadio = item.settings?.showSourceRadio ?? true;
|
||||
const mediaType = item.settings?.resourceType ?? 'icon';
|
||||
const mediaType = item.settings?.resourceType ?? MediaType.Icon;
|
||||
const folderName = item.settings?.folderName ?? ResourceFolderName.Icon;
|
||||
let srcPath = '';
|
||||
if (mediaType === 'icon') {
|
||||
if (mediaType === MediaType.Icon) {
|
||||
if (value?.fixed) {
|
||||
srcPath = getPublicOrAbsoluteUrl(value.fixed);
|
||||
} else if (item.settings?.placeholderValue) {
|
||||
@ -87,17 +73,6 @@ export const ResourceDimensionEditor: FC<
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} title={`Select ${mediaType}`} onDismiss={() => setOpen(false)} closeOnEscape>
|
||||
<ResourcePicker
|
||||
onChange={onFixedChange}
|
||||
value={value?.fixed}
|
||||
mediaType={mediaType}
|
||||
folderName={folderName}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{showSourceRadio && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Source" labelWidth={labelWidth} grow={true}>
|
||||
@ -118,17 +93,16 @@ export const ResourceDimensionEditor: FC<
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
{mode === ResourceDimensionMode.Fixed && (
|
||||
<InlineFieldRow onClick={openModal} className={styles.pointer}>
|
||||
<InlineField label={null} grow>
|
||||
<Input
|
||||
value={niceName(value?.fixed) ?? ''}
|
||||
placeholder={item.settings?.placeholderText ?? 'Select a value'}
|
||||
readOnly={true}
|
||||
prefix={srcPath && <SVG src={srcPath} className={styles.icon} />}
|
||||
suffix={<Button icon="times" variant="secondary" fill="text" size="sm" onClick={onClear} />}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<ResourcePicker
|
||||
onChange={onFixedChange}
|
||||
onClear={onClear}
|
||||
value={value?.fixed}
|
||||
src={srcPath}
|
||||
placeholder={item.settings?.placeholderText ?? 'Select a value'}
|
||||
name={niceName(value?.fixed) ?? ''}
|
||||
mediaType={mediaType}
|
||||
folderName={folderName}
|
||||
/>
|
||||
)}
|
||||
{mode === ResourceDimensionMode.Mapping && (
|
||||
<InlineFieldRow>
|
||||
@ -151,18 +125,3 @@ export function niceName(value?: string): string | undefined {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
icon: css`
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
max-width: 25px;
|
||||
`,
|
||||
pointer: css`
|
||||
cursor: pointer;
|
||||
input[readonly] {
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
@ -1,224 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { Button, InlineField, InlineFieldRow, Input, Popover, PopoverController, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import SVG from 'react-inlinesvg';
|
||||
import { Button, Select, FilterInput, useTheme2, stylesFactory, Field, Modal, Label, Input } from '@grafana/ui';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { ResourceCards } from './ResourceCards';
|
||||
import { getPublicOrAbsoluteUrl } from '../resource';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { FileElement, GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource';
|
||||
import { ResourceFolderName } from '..';
|
||||
import { MediaType, ResourceFolderName } from '../types';
|
||||
import { closePopover } from '@grafana/ui/src/utils/closePopover';
|
||||
import { ResourcePickerPopover } from './ResourcePickerPopover';
|
||||
|
||||
interface Props {
|
||||
value?: string; //img/icons/unicons/0-plus.svg
|
||||
src?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
onChange: (value?: string) => void;
|
||||
mediaType: 'icon' | 'image';
|
||||
onClear: (event: React.MouseEvent) => void;
|
||||
mediaType: MediaType;
|
||||
folderName: ResourceFolderName;
|
||||
setOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ResourceItem {
|
||||
label: string;
|
||||
value: string; // includes folder
|
||||
search: string;
|
||||
imgUrl: string;
|
||||
}
|
||||
|
||||
const sourceOptions = [
|
||||
{ label: `Folder`, value: 'folder' },
|
||||
{ label: 'URL', value: 'url' },
|
||||
// { label: 'Upload', value: 'upload' }, TODO
|
||||
];
|
||||
|
||||
const getFolders = (mediaType: 'icon' | 'image') => {
|
||||
if (mediaType === 'icon') {
|
||||
return [ResourceFolderName.Icon, ResourceFolderName.IOT, ResourceFolderName.Marker];
|
||||
} else {
|
||||
return [ResourceFolderName.BG];
|
||||
}
|
||||
};
|
||||
|
||||
const getFolderIfExists = (folders: Array<SelectableValue<string>>, path: string) => {
|
||||
return folders.find((folder) => path.startsWith(folder.value!)) ?? folders[0];
|
||||
};
|
||||
|
||||
export const ResourcePicker = (props: Props) => {
|
||||
const { value, onChange, mediaType, folderName, setOpen } = props;
|
||||
const folders = getFolders(mediaType).map((v) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
}));
|
||||
const { value, src, name, placeholder, onChange, onClear, mediaType, folderName } = props;
|
||||
|
||||
const [currentFolder, setCurrentFolder] = useState<SelectableValue<string>>(
|
||||
getFolderIfExists(folders, value?.length ? value : folderName)
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const pickerTriggerRef = createRef<any>();
|
||||
const popoverElement = (
|
||||
<ResourcePickerPopover onChange={onChange} value={value} mediaType={mediaType} folderName={folderName} />
|
||||
);
|
||||
const [directoryIndex, setDirectoryIndex] = useState<ResourceItem[]>([]);
|
||||
const [filteredIndex, setFilteredIndex] = useState<ResourceItem[]>([]);
|
||||
// select between existing icon folder, url, or upload
|
||||
const [source, setSource] = useState<SelectableValue<string>>(sourceOptions[0]);
|
||||
// pass on new value to confirm button and to show in preview
|
||||
const [newValue, setNewValue] = useState<string>(value ?? '');
|
||||
const [searchQuery, setSearchQuery] = useState<string>();
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
useEffect(() => {
|
||||
// we don't want to load everything before picking a folder
|
||||
const folder = currentFolder?.value;
|
||||
if (folder) {
|
||||
const filter =
|
||||
mediaType === 'icon'
|
||||
? (item: FileElement) => item.name.endsWith('.svg')
|
||||
: (item: FileElement) => item.name.endsWith('.png') || item.name.endsWith('.gif');
|
||||
|
||||
getDatasourceSrv()
|
||||
.get('-- Grafana --')
|
||||
.then((ds) => {
|
||||
(ds as GrafanaDatasource).listFiles(folder).subscribe({
|
||||
next: (frame) => {
|
||||
const cards: ResourceItem[] = [];
|
||||
frame.forEach((item) => {
|
||||
if (filter(item)) {
|
||||
const idx = item.name.lastIndexOf('.');
|
||||
cards.push({
|
||||
value: `${folder}/${item.name}`,
|
||||
label: item.name,
|
||||
search: (idx ? item.name.substr(0, idx) : item.name).toLowerCase(),
|
||||
imgUrl: `public/${folder}/${item.name}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
setDirectoryIndex(cards);
|
||||
setFilteredIndex(cards);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [mediaType, currentFolder]);
|
||||
|
||||
const onChangeSearch = (query: string) => {
|
||||
if (query) {
|
||||
query = query.toLowerCase();
|
||||
setFilteredIndex(directoryIndex.filter((card) => card.search.includes(query)));
|
||||
} else {
|
||||
setFilteredIndex(directoryIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const imgSrc = getPublicOrAbsoluteUrl(newValue!);
|
||||
|
||||
let shortName = newValue?.substring(newValue.lastIndexOf('/') + 1, newValue.lastIndexOf('.'));
|
||||
if (shortName.length > 20) {
|
||||
shortName = shortName.substring(0, 20) + '...';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.upper}>
|
||||
<div className={styles.child}>
|
||||
<Field label="Source">
|
||||
<Select menuShouldPortal={true} options={sourceOptions} onChange={setSource} value={source} />
|
||||
</Field>
|
||||
{source?.value === 'folder' && (
|
||||
<>
|
||||
<Field label="Folder">
|
||||
<Select menuShouldPortal={true} options={folders} onChange={setCurrentFolder} value={currentFolder} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FilterInput
|
||||
value={searchQuery ?? ''}
|
||||
placeholder="Search"
|
||||
onChange={(v) => {
|
||||
onChangeSearch(v);
|
||||
setSearchQuery(v);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{source?.value === 'url' && (
|
||||
<Field label="URL">
|
||||
<Input onChange={(e) => setNewValue(e.currentTarget.value)} value={newValue} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.iconContainer}>
|
||||
<Field label="Preview">
|
||||
<div className={styles.iconPreview}>
|
||||
{mediaType === 'icon' && <SVG src={imgSrc} className={styles.img} />}
|
||||
{mediaType === 'image' && newValue && <img src={imgSrc} className={styles.img} />}
|
||||
</div>
|
||||
</Field>
|
||||
<Label>{shortName}</Label>
|
||||
</div>
|
||||
</div>
|
||||
{source?.value === 'folder' && filteredIndex && (
|
||||
<div className={styles.cardsWrapper}>
|
||||
<ResourceCards cards={filteredIndex} onChange={(v) => setNewValue(v)} value={newValue} />
|
||||
</div>
|
||||
)}
|
||||
<PopoverController content={popoverElement}>
|
||||
{(showPopper, hidePopper, popperProps) => {
|
||||
return (
|
||||
<>
|
||||
{pickerTriggerRef.current && (
|
||||
<Popover
|
||||
{...popperProps}
|
||||
referenceElement={pickerTriggerRef.current}
|
||||
onMouseEnter={showPopper}
|
||||
onKeyDown={(event: any) => {
|
||||
closePopover(event, hidePopper);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant={newValue && newValue !== value ? 'primary' : 'secondary'} onClick={() => onChange(newValue)}>
|
||||
Select
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
{/* TODO: add file upload
|
||||
{tabs[1].active && (
|
||||
<FileUpload
|
||||
onFileUpload={({ currentTarget }) => console.log('file', currentTarget?.files && currentTarget.files[0])}
|
||||
className={styles.tabContent}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
<div ref={pickerTriggerRef} onClick={showPopper}>
|
||||
<InlineFieldRow className={styles.pointer}>
|
||||
<InlineField label={null} grow>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={placeholder}
|
||||
readOnly={true}
|
||||
prefix={src && <SVG src={src} className={styles.icon} />}
|
||||
suffix={<Button icon="times" variant="secondary" fill="text" size="sm" onClick={onClear} />}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</PopoverController>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
cardsWrapper: css`
|
||||
height: 30vh;
|
||||
min-height: 50px;
|
||||
margin-top: 5px;
|
||||
max-width: 680px;
|
||||
`,
|
||||
tabContent: css`
|
||||
margin-top: 20px;
|
||||
& > :nth-child(2) {
|
||||
margin-top: 10px;
|
||||
},
|
||||
`,
|
||||
iconPreview: css`
|
||||
width: 95px;
|
||||
height: 79px;
|
||||
border: 1px solid ${theme.colors.border.medium};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`,
|
||||
iconContainer: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 40%;
|
||||
align-items: center;
|
||||
`,
|
||||
img: css`
|
||||
width: 49px;
|
||||
height: 49px;
|
||||
fill: ${theme.colors.text.primary};
|
||||
`,
|
||||
child: css`
|
||||
width: 60%;
|
||||
`,
|
||||
upper: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`,
|
||||
};
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
pointer: css`
|
||||
cursor: pointer;
|
||||
input[readonly] {
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
icon: css`
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
max-width: 25px;
|
||||
`,
|
||||
});
|
||||
|
147
public/app/features/dimensions/editors/ResourcePickerPopover.tsx
Normal file
147
public/app/features/dimensions/editors/ResourcePickerPopover.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { createRef, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { Button, ButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import { useOverlay } from '@react-aria/overlays';
|
||||
|
||||
import { MediaType, PickerTabType, ResourceFolderName } from '../types';
|
||||
import { FolderPickerTab } from './FolderPickerTab';
|
||||
import { URLPickerTab } from './URLPickerTab';
|
||||
|
||||
interface Props {
|
||||
value?: string; //img/icons/unicons/0-plus.svg
|
||||
onChange: (value?: string) => void;
|
||||
mediaType: MediaType;
|
||||
folderName: ResourceFolderName;
|
||||
}
|
||||
|
||||
export const ResourcePickerPopover = (props: Props) => {
|
||||
const { value, onChange, mediaType, folderName } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onClose = () => {
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const ref = createRef<HTMLElement>();
|
||||
const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen: true }, ref);
|
||||
|
||||
const [newValue, setNewValue] = useState<string>(value ?? '');
|
||||
const [activePicker, setActivePicker] = useState<PickerTabType>(PickerTabType.Folder);
|
||||
|
||||
const getTabClassName = (tabName: PickerTabType) => {
|
||||
return `${styles.resourcePickerPopoverTab} ${activePicker === tabName && styles.resourcePickerPopoverActiveTab}`;
|
||||
};
|
||||
|
||||
const renderFolderPicker = () => (
|
||||
<FolderPickerTab
|
||||
value={value}
|
||||
mediaType={mediaType}
|
||||
folderName={folderName}
|
||||
newValue={newValue}
|
||||
setNewValue={setNewValue}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderURLPicker = () => <URLPickerTab newValue={newValue} setNewValue={setNewValue} mediaType={mediaType} />;
|
||||
|
||||
const renderPicker = () => {
|
||||
switch (activePicker) {
|
||||
case PickerTabType.Folder:
|
||||
return renderFolderPicker();
|
||||
case PickerTabType.URL:
|
||||
return renderURLPicker();
|
||||
default:
|
||||
return renderFolderPicker();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusScope contain autoFocus restoreFocus>
|
||||
<section ref={ref} {...overlayProps}>
|
||||
<div className={styles.resourcePickerPopover}>
|
||||
<div className={styles.resourcePickerPopoverTabs}>
|
||||
<button
|
||||
className={getTabClassName(PickerTabType.Folder)}
|
||||
onClick={() => setActivePicker(PickerTabType.Folder)}
|
||||
>
|
||||
Folder
|
||||
</button>
|
||||
<button className={getTabClassName(PickerTabType.URL)} onClick={() => setActivePicker(PickerTabType.URL)}>
|
||||
URL
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.resourcePickerPopoverContent}>
|
||||
{renderPicker()}
|
||||
<ButtonGroup className={styles.buttonGroup}>
|
||||
<Button className={styles.button} variant={'secondary'} onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant={newValue && newValue !== value ? 'primary' : 'secondary'}
|
||||
onClick={() => onChange(newValue)}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</FocusScope>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
resourcePickerPopover: css`
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
box-shadow: ${theme.shadows.z3};
|
||||
background: ${theme.colors.background.primary};
|
||||
border: 1px solid ${theme.colors.border.medium};
|
||||
`,
|
||||
resourcePickerPopoverTab: css`
|
||||
width: 50%;
|
||||
text-align: center;
|
||||
padding: ${theme.spacing(1, 0)};
|
||||
background: ${theme.colors.background.secondary};
|
||||
color: ${theme.colors.text.secondary};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
position: relative;
|
||||
}
|
||||
`,
|
||||
resourcePickerPopoverActiveTab: css`
|
||||
color: ${theme.colors.text.primary};
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
background: ${theme.colors.background.primary};
|
||||
`,
|
||||
resourcePickerPopoverContent: css`
|
||||
width: 315px;
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
min-height: 184px;
|
||||
padding: ${theme.spacing(1)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
resourcePickerPopoverTabs: css`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-radius: ${theme.shape.borderRadius()} ${theme.shape.borderRadius()} 0 0;
|
||||
`,
|
||||
buttonGroup: css`
|
||||
align-self: center;
|
||||
flex-direction: row;
|
||||
`,
|
||||
button: css`
|
||||
margin: 12px 20px 5px;
|
||||
`,
|
||||
});
|
66
public/app/features/dimensions/editors/URLPickerTab.tsx
Normal file
66
public/app/features/dimensions/editors/URLPickerTab.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { Dispatch, SetStateAction } from 'react';
|
||||
import { Field, Input, Label, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
import SVG from 'react-inlinesvg';
|
||||
|
||||
import { MediaType } from '../types';
|
||||
import { getPublicOrAbsoluteUrl } from '../resource';
|
||||
|
||||
interface Props {
|
||||
newValue: string;
|
||||
setNewValue: Dispatch<SetStateAction<string>>;
|
||||
mediaType: MediaType;
|
||||
}
|
||||
|
||||
export const URLPickerTab = (props: Props) => {
|
||||
const { newValue, setNewValue, mediaType } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const imgSrc = getPublicOrAbsoluteUrl(newValue!);
|
||||
|
||||
let shortName = newValue?.substring(newValue.lastIndexOf('/') + 1, newValue.lastIndexOf('.'));
|
||||
if (shortName.length > 20) {
|
||||
shortName = shortName.substring(0, 20) + '...';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<Input onChange={(e) => setNewValue(e.currentTarget.value)} value={newValue} />
|
||||
</Field>
|
||||
<div className={styles.iconContainer}>
|
||||
<Field label="Preview">
|
||||
<div className={styles.iconPreview}>
|
||||
{mediaType === MediaType.Icon && <SVG src={imgSrc} className={styles.img} />}
|
||||
{mediaType === MediaType.Image && newValue && <img src={imgSrc} className={styles.img} />}
|
||||
</div>
|
||||
</Field>
|
||||
<Label>{shortName}</Label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
iconContainer: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 80%;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
`,
|
||||
iconPreview: css`
|
||||
width: 238px;
|
||||
height: 198px;
|
||||
border: 1px solid ${theme.colors.border.medium};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`,
|
||||
img: css`
|
||||
width: 147px;
|
||||
height: 147px;
|
||||
fill: ${theme.colors.text.primary};
|
||||
`,
|
||||
});
|
@ -91,7 +91,7 @@ export interface ColorDimensionConfig extends BaseDimensionConfig<string> {}
|
||||
|
||||
/** Places that use the value */
|
||||
export interface ResourceDimensionOptions {
|
||||
resourceType: 'icon' | 'image';
|
||||
resourceType: MediaType;
|
||||
folderName?: ResourceFolderName;
|
||||
placeholderText?: string;
|
||||
placeholderValue?: string;
|
||||
@ -117,3 +117,13 @@ export enum ResourceFolderName {
|
||||
Marker = 'img/icons/marker',
|
||||
BG = 'img/bg',
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
Icon = 'icon',
|
||||
Image = 'image',
|
||||
}
|
||||
|
||||
export enum PickerTabType {
|
||||
Folder = 'folder',
|
||||
URL = 'url',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user