diff --git a/ui/src/common/TagChip.tsx b/ui/src/common/TagChip.tsx index ef44babb..0134f4aa 100644 --- a/ui/src/common/TagChip.tsx +++ b/ui/src/common/TagChip.tsx @@ -2,24 +2,36 @@ import Chip from '@material-ui/core/Chip'; import * as React from 'react'; // @ts-ignore import bestContrast from 'get-best-contrast-color'; +import {makeStyles, Theme} from '@material-ui/core'; -export const TagChip = ({color, label}: {label: string; color: string}) => { +const useStyles = makeStyles((theme: Theme) => ({ + chip: { + margin: theme.spacing(0.5, 0.6), + cursor: 'text', + minHeight: '32px', + height: 'fit-content', + whiteSpace: 'normal', + wordBreak: 'break-word', + }, +})); + +interface TagChipProps { + label: string; + color: string; + onClick?: () => void; +} + +export const TagChip: React.FC = ({color, label, onClick}) => { + const classes = useStyles(); const textColor = bestContrast(color, ['#fff', '#000']); return ( ); }; diff --git a/ui/src/dashboard/Entry/DashboardEntryForm.tsx b/ui/src/dashboard/Entry/DashboardEntryForm.tsx index ae5d02b1..576b53e2 100644 --- a/ui/src/dashboard/Entry/DashboardEntryForm.tsx +++ b/ui/src/dashboard/Entry/DashboardEntryForm.tsx @@ -3,8 +3,12 @@ import TextField from '@material-ui/core/TextField'; import FormControl from '@material-ui/core/FormControl'; import InputLabel from '@material-ui/core/InputLabel'; import Select from '@material-ui/core/NativeSelect/NativeSelect'; +import {useQuery} from 'react-apollo'; +import {Tags} from '../../gql/__generated__/Tags'; +import * as gqlTags from '../../gql/tags'; import {EntryType, StatsInterval} from '../../gql/__generated__/globalTypes'; -import {TagKeySelector} from '../../tag/TagKeySelector'; +import {toTagSelectorEntry} from '../../tag/tagSelectorEntry'; +import {FormTagSelector} from '../../tag/FormTagSelector'; import {Dashboards_dashboards_items, Dashboards_dashboards_items_statsSelection_range} from '../../gql/__generated__/Dashboards'; import {RelativeDateTimeSelector} from '../../common/RelativeDateTimeSelector'; import {parseRelativeTime} from '../../utils/time'; @@ -31,6 +35,14 @@ export const isValidDashboardEntry = (item: Dashboards_dashboards_items): boolea export const DashboardEntryForm: React.FC = ({entry, onChange: setEntry, disabled = false, ranges}) => { const [staticRange, setStaticRange] = React.useState(!entry.statsSelection.rangeId); + const tagsResult = useQuery(gqlTags.Tags); + + let tagKeys; + if (!tagsResult.error && !tagsResult.loading && tagsResult.data && tagsResult.data.tags) { + const keyInputTags = (entry.statsSelection.tags || []).map((key) => ({key, value: ''})); + tagKeys = toTagSelectorEntry(tagsResult.data.tags, keyInputTags); + } + const range: Dashboards_dashboards_items_statsSelection_range = entry.statsSelection.range ? entry.statsSelection.range : { @@ -181,13 +193,16 @@ export const DashboardEntryForm: React.FC = ({entry, onChange: s ) : ( undefined )} - { - entry.statsSelection.tags = tags; + { + entry.statsSelection.tags = tags.map((tag) => tag.tag.key); setEntry(entry); }} + createTags={false} + onlySelectKeys + removeWhenClicked /> ); diff --git a/ui/src/tag/FormTagSelector.tsx b/ui/src/tag/FormTagSelector.tsx new file mode 100644 index 00000000..8113669a --- /dev/null +++ b/ui/src/tag/FormTagSelector.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import FormControl from '@material-ui/core/FormControl'; +import Box from '@material-ui/core/Box'; +import InputLabel from '@material-ui/core/InputLabel'; +import {TagSelector, TagSelectorProps} from './TagSelector'; + +interface FormTagSelectorProps extends TagSelectorProps { + label: string; + required?: boolean; +} + +export const FormTagSelector = ({label, required = false, ...props}: FormTagSelectorProps) => { + return ( + + + + {label} + + + + + + + ); +}; diff --git a/ui/src/tag/TagKeySelector.tsx b/ui/src/tag/TagKeySelector.tsx deleted file mode 100644 index ab4b3f39..00000000 --- a/ui/src/tag/TagKeySelector.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from 'react'; -import Downshift from 'downshift'; -import {makeStyles, Theme} from '@material-ui/core/styles'; -import TextField from '@material-ui/core/TextField'; -import Paper from '@material-ui/core/Paper'; -import MenuItem from '@material-ui/core/MenuItem'; -import Chip from '@material-ui/core/Chip'; -import {useQuery} from '@apollo/react-hooks'; -import {Tags} from '../gql/__generated__/Tags'; -import * as gqlTags from '../gql/tags'; -import {useSuggest} from './suggest'; - -const useStyles = makeStyles((theme: Theme) => ({ - root: { - flexGrow: 1, - height: 250, - }, - container: { - flexGrow: 1, - position: 'relative', - }, - paper: { - position: 'absolute', - zIndex: 1, - marginTop: theme.spacing(1), - left: 0, - right: 0, - }, - chip: { - margin: theme.spacing(0.5, 0.25), - }, - inputRoot: { - flexWrap: 'wrap', - }, - inputInput: { - width: 'auto', - flexGrow: 1, - }, -})); - -interface TagKeySelectorProps { - value: string[]; - onChange: (entries: string[]) => void; - disabled?: boolean; -} - -export const TagKeySelector: React.FC = ({value: selectedItem, onChange, disabled = false}) => { - const classes = useStyles(); - const [inputValue, setInputValue] = React.useState(''); - - const tagsResult = useQuery(gqlTags.Tags); - const suggestions = useSuggest(tagsResult, inputValue, selectedItem, true) - .filter((t) => !t.tag.create && !t.tag.alreadyUsed) - .map((t) => t.tag.key); - - if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { - return null; - } - - function handleKeyDown(event: React.KeyboardEvent) { - if (selectedItem.length && !inputValue.length && event.key === 'Backspace') { - onChange(selectedItem.slice(0, selectedItem.length - 1)); - } - } - - function handleInputChange(event: React.ChangeEvent<{value: string}>) { - setInputValue(event.target.value); - } - - function handleChange(item: string) { - let newSelectedItem = [...selectedItem]; - if (newSelectedItem.indexOf(item) === -1) { - newSelectedItem = [...newSelectedItem, item]; - } - setInputValue(''); - onChange(newSelectedItem); - } - - const handleDelete = (item: string) => () => { - const newSelectedItem = [...selectedItem]; - newSelectedItem.splice(newSelectedItem.indexOf(item), 1); - onChange(newSelectedItem); - }; - - return ( - - {({getInputProps, getItemProps, getLabelProps, isOpen, highlightedIndex}) => { - const {onBlur, onChange: downshiftOnChange, onFocus, ...inputProps} = getInputProps({ - onKeyDown: handleKeyDown, - placeholder: 'Select Tags', - }); - return ( -
- ( - - )), - onBlur, - onChange: (event) => { - handleInputChange(event); - downshiftOnChange!(event as React.ChangeEvent); - }, - onFocus, - }} - label={'Tags'} - fullWidth - inputProps={inputProps} - /> - {isOpen ? ( - - {suggestions.map((suggestion, index) => ( - - {suggestion} - - ))} - - ) : null} -
- ); - }} -
- ); -}; diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 019e16f6..74d2319e 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -14,12 +14,30 @@ import ClickAwayListener from '@material-ui/core/ClickAwayListener'; import Input from '@material-ui/core/Input'; import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks'; import {TagChip} from '../common/TagChip'; +import {makeStyles, Theme} from '@material-ui/core/styles'; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + width: '100%', + }, + inputRoot: {display: 'flex', flexWrap: 'wrap', cursor: 'text', width: '100%'}, + inputInput: {height: 40, minWidth: 150, flexGrow: 1}, + paper: { + position: 'absolute', + zIndex: 1, + marginTop: theme.spacing(1), + }, +})); export interface TagSelectorProps { onSelectedEntriesChanged: (entries: TagSelectorEntry[]) => void; selectedEntries: TagSelectorEntry[]; dialogOpen?: React.Dispatch>; onCtrlEnter?: () => void; + createTags?: boolean; + allowDuplicateKeys?: boolean; + onlySelectKeys?: boolean; + removeWhenClicked?: boolean; } export const TagSelector: React.FC = ({ @@ -27,7 +45,12 @@ export const TagSelector: React.FC = ({ onSelectedEntriesChanged: setSelectedEntries, dialogOpen = () => {}, onCtrlEnter, + createTags = true, + allowDuplicateKeys = false, + onlySelectKeys = false, + removeWhenClicked = false, }) => { + const classes = useStyles(); const [tooltipErrorActive, tooltipError, showTooltipError] = useError(4000); const [open, setOpen] = React.useState(false); const [currentValue, setCurrentValueInternal] = React.useState(''); @@ -37,11 +60,8 @@ export const TagSelector: React.FC = ({ const container = React.useRef(null); const tagsResult = useQuery(gqlTags.Tags); - const suggestions = useSuggest( - tagsResult, - currentValue, - selectedEntries.map((t) => t.tag.key) - ); + + const suggestions = useSuggest(tagsResult, currentValue, selectedEntries, onlySelectKeys, allowDuplicateKeys, createTags); if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { return null; @@ -54,7 +74,7 @@ export const TagSelector: React.FC = ({ setHighlightedIndex(0); if (newValue.indexOf(' ') !== -1) { - const {errors, entries} = addValues(newValue, tagsResult, selectedEntries); + const {errors, entries} = addValues(newValue, tagsResult, selectedEntries, onlySelectKeys, allowDuplicateKeys); setSelectedEntries([...selectedEntries, ...entries]); @@ -91,7 +111,7 @@ export const TagSelector: React.FC = ({ focusInput(); - if (!entry.value) { + if (!onlySelectKeys && !entry.value) { const newValue = entry.tag.key + ':'; if (currentValue !== newValue) { setHighlightedIndex(0); @@ -107,12 +127,22 @@ export const TagSelector: React.FC = ({ return; }; + const onTagClicked = (entry: TagSelectorEntry) => { + if (!removeWhenClicked) { + return; + } + const tagIndex = selectedEntries.indexOf(entry); + selectedEntries.splice(tagIndex, 1); + + setSelectedEntries(selectedEntries); + }; + const onKeyDown = (event: React.KeyboardEvent) => { if (!currentValue && selectedEntries.length && event.key === 'Backspace') { event.preventDefault(); const last = selectedEntries[selectedEntries.length - 1]; setSelectedEntries(selectedEntries.slice(0, selectedEntries.length - 1)); - setCurrentValue(event.ctrlKey ? '' : label(last)); + setCurrentValue(event.ctrlKey ? '' : itemLabel(last, onlySelectKeys)); } if (event.key === 'ArrowUp') { event.preventDefault(); @@ -134,11 +164,14 @@ export const TagSelector: React.FC = ({ event.preventDefault(); setOpen(false); } + if (event.key === 'Tab') { + setOpen(false); + } }; return ( setOpen(false)}> -
+
= ({ {tooltipError} }> -
(container.current = ref)} - style={{display: 'flex', flexWrap: 'wrap', cursor: 'text', width: '100%'}} - onClick={focusInput}> - {toChips(selectedEntries)} +
(container.current = ref)} className={classes.inputRoot} onClick={focusInput}> + {toChips(selectedEntries, onlySelectKeys, onTagClicked)} = ({ disableUnderline={true} onChange={(e) => setCurrentValue(e.target.value)} placeholder="Enter Tags" - style={{height: 40, minWidth: 150, flexGrow: 1}} + className={classes.inputInput} />
{open ? ( + className={classes.paper} + style={{width: (container.current && container.current.clientWidth) || 300}} + square> {suggestions.map((entry, index) => ( - + ))} ) : null} @@ -200,10 +234,11 @@ export const TagSelector: React.FC = ({ interface ItemProps { entry: TagSelectorEntry; selected: boolean; + onlySelectKeys: boolean; onClick: (entry: TagSelectorEntry) => void; } -const Item: React.FC = ({entry, selected, onClick}) => { +const Item: React.FC = ({entry, selected, onlySelectKeys, onClick}) => { return ( = ({entry, selected, onClick}) => { style={{ fontWeight: selected ? 500 : 400, }}> - {itemLabel(entry)} + {itemLabel(entry, onlySelectKeys)} ); }; -const toChips = (entries: TagSelectorEntry[]) => { - return entries.map((entry) => ); +const toChips = (entries: TagSelectorEntry[], onlySelectKeys: boolean, onClick: (entry: TagSelectorEntry) => void) => { + return entries.map((entry) => ( + onClick(entry)} + /> + )); }; diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts index 31a6f823..c1992dff 100644 --- a/ui/src/tag/suggest.ts +++ b/ui/src/tag/suggest.ts @@ -8,8 +8,10 @@ import {QueryResult} from 'react-apollo'; export const useSuggest = ( tagResult: QueryResult, inputValue: string, - usedTags: string[], - skipValue = false + usedTags: TagSelectorEntry[], + skipValue = false, + allowDuplicateKeys = false, + createTags = true ): TagSelectorEntry[] => { const [tagKeySomeCase, tagValue] = inputValue.split(':'); const tagKey = tagKeySomeCase.toLowerCase(); @@ -21,10 +23,13 @@ export const useSuggest = ( skip: exactMatch === undefined || skipValue, }); - if (exactMatch && tagValue !== undefined && usedTags.indexOf(exactMatch.key) === -1 && !skipValue) { - return suggestTagValue(exactMatch, tagValue, valueResult); + const usedKeys = usedTags.map((t) => t.tag.key); + const usedValues = usedTags.map((t) => t.value); + + if (exactMatch && tagValue !== undefined && !skipValue && (allowDuplicateKeys || usedKeys.indexOf(exactMatch.key) === -1)) { + return suggestTagValue(exactMatch, tagValue, valueResult, usedValues, createTags); } else { - return suggestTag(exactMatch, tagResult, tagKey, usedTags); + return suggestTag(exactMatch, tagResult, tagKey, usedKeys, allowDuplicateKeys, createTags); } }; @@ -32,21 +37,23 @@ const suggestTag = ( exactMatch: TagSelectorEntry['tag'] | undefined, tagResult: QueryResult, tagKey: string, - usedTags: string[] + usedTags: string[], + allowDuplicateKeys: boolean, + createTags: boolean ) => { if (!tagResult.data || tagResult.data.tags === null) { return []; } let availableTags = (tagResult.data.tags || []) - .filter((tag) => usedTags.indexOf(tag.key) === -1) + .filter((tag) => allowDuplicateKeys || usedTags.indexOf(tag.key) === -1) .filter((tag) => tag.key.indexOf(tagKey) === 0); - if (tagKey && !exactMatch) { + if (tagKey && !exactMatch && createTags) { availableTags = [specialTag(tagKey, 'new'), ...availableTags]; } - if (usedTags.indexOf(tagKey) !== -1) { + if (usedTags.indexOf(tagKey) !== -1 && !allowDuplicateKeys) { availableTags = [specialTag(tagKey, 'used'), ...availableTags]; } @@ -59,13 +66,24 @@ const suggestTag = ( const suggestTagValue = ( exactMatch: TagSelectorEntry['tag'], tagValue: string, - valueResult: QueryResult + valueResult: QueryResult, + usedValues: string[], + includeInputValueOnNoMatch: boolean ): TagSelectorEntry[] => { let someValues = (valueResult.data && valueResult.data.values) || []; - if (someValues.indexOf(tagValue) === -1) { + if (includeInputValueOnNoMatch && someValues.indexOf(tagValue) === -1) { someValues = [tagValue, ...someValues]; } + if (someValues.length === 0 && !includeInputValueOnNoMatch) { + return [{tag: specialTag(exactMatch.key, 'no_values'), value: tagValue}]; + } + + someValues = someValues.filter((val) => usedValues.indexOf(val) === -1); + if (someValues.length === 0 && !includeInputValueOnNoMatch) { + return [{tag: specialTag(exactMatch.key, 'all_values_used'), value: ''}]; + } + return someValues.map((val) => ({tag: exactMatch, value: val})); }; diff --git a/ui/src/tag/tagSelectorEntry.ts b/ui/src/tag/tagSelectorEntry.ts index 251f4468..b7ceb927 100644 --- a/ui/src/tag/tagSelectorEntry.ts +++ b/ui/src/tag/tagSelectorEntry.ts @@ -6,8 +6,16 @@ export interface TagInputError { value: string; } +type SpecialTagState = 'used' | 'new' | 'no_values' | 'all_values_used'; +export interface SpecialTag { + create?: boolean; + alreadyUsed?: boolean; + noValues?: boolean; + allValuesUsed?: boolean; +} + export interface TagSelectorEntry { - tag: Omit & {create?: boolean; alreadyUsed?: boolean}; + tag: Omit & SpecialTag; value: string; } @@ -32,18 +40,20 @@ export const toTagSelectorEntry = (tags: Array, entries ); }; -export const specialTag = (name: string, state: 'used' | 'new'): TagSelectorEntry['tag'] & {usages: 0} => { +export const specialTag = (name: string, state: SpecialTagState): TagSelectorEntry['tag'] & {usages: 0} => { return { key: name, __typename: 'TagDefinition', color: 'gray', create: state === 'new', alreadyUsed: state === 'used', + noValues: state === 'no_values', + allValuesUsed: state === 'all_values_used', usages: 0, }; }; -const tryAdd = (tagsResult: QueryResult, entry: string): TagSelectorEntry | TagInputError => { +const tryAdd = (tagsResult: QueryResult, entry: string, onlyKeys: boolean): TagSelectorEntry | TagInputError => { const [keySomeCase, value, ...other] = entry.split(':'); const key = keySomeCase.toLowerCase(); @@ -57,11 +67,15 @@ const tryAdd = (tagsResult: QueryResult, entry: string): TagSelectorEn return {error: `'${key}' does not exist`, value: entry}; } - if (!value) { + if (onlyKeys && value) { + return {error: `'${key}' has a value, but this field doesn't allow them`, value: entry}; + } + + if (!onlyKeys && !value) { return {error: `'${key}' requires a value`, value: entry}; } - return {tag: foundTag, value}; + return {tag: foundTag, value: value || ''}; }; interface EntriesAndErrors { errors: TagInputError[]; @@ -69,39 +83,56 @@ interface EntriesAndErrors { usedTags: string[]; } -const groupAndCheckExistence = (a: EntriesAndErrors, entry: TagInputError | TagSelectorEntry) => { - if ('tag' in entry) { - if (a.usedTags.indexOf(entry.tag.key) === -1) { - a.entries = [...a.entries, entry]; - a.usedTags = [...a.usedTags, entry.tag.key]; +const groupAndCheckExistence = (onlyKeys: boolean, allowDuplicateKeys: boolean) => { + return (a: EntriesAndErrors, entry: TagInputError | TagSelectorEntry) => { + if ('tag' in entry) { + if (allowDuplicateKeys || a.usedTags.indexOf(entry.tag.key) === -1) { + a.entries = [...a.entries, entry]; + a.usedTags = [...a.usedTags, entry.tag.key]; + } else { + a.errors = [...a.errors, {value: itemLabel(entry, onlyKeys), error: `'${entry.tag.key}' is already defined`}]; + } } else { - a.errors = [...a.errors, {value: label(entry), error: `'${entry.tag.key}' is already defined`}]; + a.errors = [...a.errors, entry]; } - } else { - a.errors = [...a.errors, entry]; - } - return a; + return a; + }; }; export const addValues = ( newValue: string, tagsResult: QueryResult, - selectedEntries: TagSelectorEntry[] + selectedEntries: TagSelectorEntry[], + onlyKeys: boolean, + allowDuplicateKeys: boolean ): EntriesAndErrors => { return newValue .split(/\s+/) .filter((entry) => entry) - .map((entry) => tryAdd(tagsResult, entry)) - .reduce(groupAndCheckExistence, {errors: [], entries: [], usedTags: selectedEntries.map((entry) => entry.tag.key)}); + .map((entry) => tryAdd(tagsResult, entry, onlyKeys)) + .reduce(groupAndCheckExistence(onlyKeys, allowDuplicateKeys), { + errors: [], + entries: [], + usedTags: selectedEntries.map((entry) => entry.tag.key), + }); }; -export const itemLabel = (tag: TagSelectorEntry) => { +export const itemLabel = (tag: TagSelectorEntry, onlyShowKey = false) => { if (tag.tag.create) { return `Create tag '${tag.tag.key}'`; } if (tag.tag.alreadyUsed) { return `Tag '${tag.tag.key}' is already defined`; } + if (tag.tag.noValues) { + return `Unkown value '${tag.value}' of tag '${tag.tag.key}'`; + } + if (tag.tag.allValuesUsed) { + return `All values of tag '${tag.tag.key}' are used`; + } + if (onlyShowKey) { + return tag.tag.key; + } return label(tag); };