From 8a7b4696792f9d58def7f7892836a8ee7ca827aa Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Wed, 26 Jan 2022 11:29:04 -0800 Subject: [PATCH] Resource picker/improve ux (#44402) --- .../dimensions/editors/FolderPickerTab.tsx | 129 +++++++++ .../dimensions/editors/ResourceCards.tsx | 2 +- .../editors/ResourceDimensionEditor.tsx | 77 ++--- .../dimensions/editors/ResourcePicker.tsx | 265 ++++-------------- .../editors/ResourcePickerPopover.tsx | 147 ++++++++++ .../dimensions/editors/URLPickerTab.tsx | 66 +++++ public/app/features/dimensions/types.ts | 12 +- 7 files changed, 433 insertions(+), 265 deletions(-) create mode 100644 public/app/features/dimensions/editors/FolderPickerTab.tsx create mode 100644 public/app/features/dimensions/editors/ResourcePickerPopover.tsx create mode 100644 public/app/features/dimensions/editors/URLPickerTab.tsx diff --git a/public/app/features/dimensions/editors/FolderPickerTab.tsx b/public/app/features/dimensions/editors/FolderPickerTab.tsx new file mode 100644 index 00000000000..c3e9ae05155 --- /dev/null +++ b/public/app/features/dimensions/editors/FolderPickerTab.tsx @@ -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>, 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>; +} + +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(); + + const [currentFolder, setCurrentFolder] = useState>( + getFolderIfExists(folders, value?.length ? value : folderName) + ); + const [directoryIndex, setDirectoryIndex] = useState([]); + const [filteredIndex, setFilteredIndex] = useState([]); + + 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 ( + <> + + } - suffix={ - - - {/* TODO: add file upload - {tabs[1].active && ( - console.log('file', currentTarget?.files && currentTarget.files[0])} - className={styles.tabContent} - /> - )} */} - + + ); + }} + ); }; -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; + `, }); diff --git a/public/app/features/dimensions/editors/ResourcePickerPopover.tsx b/public/app/features/dimensions/editors/ResourcePickerPopover.tsx new file mode 100644 index 00000000000..29e25dd9cef --- /dev/null +++ b/public/app/features/dimensions/editors/ResourcePickerPopover.tsx @@ -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(); + const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen: true }, ref); + + const [newValue, setNewValue] = useState(value ?? ''); + const [activePicker, setActivePicker] = useState(PickerTabType.Folder); + + const getTabClassName = (tabName: PickerTabType) => { + return `${styles.resourcePickerPopoverTab} ${activePicker === tabName && styles.resourcePickerPopoverActiveTab}`; + }; + + const renderFolderPicker = () => ( + + ); + + const renderURLPicker = () => ; + + const renderPicker = () => { + switch (activePicker) { + case PickerTabType.Folder: + return renderFolderPicker(); + case PickerTabType.URL: + return renderURLPicker(); + default: + return renderFolderPicker(); + } + }; + + return ( + +
+
+
+ + +
+
+ {renderPicker()} + + + + +
+
+
+
+ ); +}; + +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; + `, +}); diff --git a/public/app/features/dimensions/editors/URLPickerTab.tsx b/public/app/features/dimensions/editors/URLPickerTab.tsx new file mode 100644 index 00000000000..ff2638fbd9e --- /dev/null +++ b/public/app/features/dimensions/editors/URLPickerTab.tsx @@ -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>; + 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 ( + <> + + setNewValue(e.currentTarget.value)} value={newValue} /> + +
+ +
+ {mediaType === MediaType.Icon && } + {mediaType === MediaType.Image && newValue && } +
+
+ +
+ + ); +}; + +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}; + `, +}); diff --git a/public/app/features/dimensions/types.ts b/public/app/features/dimensions/types.ts index c306496524f..1b6b9e6a12f 100644 --- a/public/app/features/dimensions/types.ts +++ b/public/app/features/dimensions/types.ts @@ -91,7 +91,7 @@ export interface ColorDimensionConfig extends BaseDimensionConfig {} /** 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', +}