Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions ui/src/common/TagChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TagChipProps> = ({color, label, onClick}) => {
const classes = useStyles();
const textColor = bestContrast(color, ['#fff', '#000']);
return (
<Chip
tabIndex={-1}
variant="outlined"
style={{
background: color,
margin: '5px',
color: textColor,
cursor: 'text',
minHeight: '32px',
height: 'fit-content',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
className={classes.chip}
style={{background: color, color: textColor}}
label={label}
onClick={onClick}
/>
);
};
27 changes: 21 additions & 6 deletions ui/src/dashboard/Entry/DashboardEntryForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +35,14 @@ export const isValidDashboardEntry = (item: Dashboards_dashboards_items): boolea
export const DashboardEntryForm: React.FC<EditPopupProps> = ({entry, onChange: setEntry, disabled = false, ranges}) => {
const [staticRange, setStaticRange] = React.useState(!entry.statsSelection.rangeId);

const tagsResult = useQuery<Tags>(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
: {
Expand Down Expand Up @@ -181,13 +193,16 @@ export const DashboardEntryForm: React.FC<EditPopupProps> = ({entry, onChange: s
) : (
undefined
)}
<TagKeySelector
value={entry.statsSelection.tags || []}
disabled={disabled}
onChange={(tags) => {
entry.statsSelection.tags = tags;
<FormTagSelector
label="Tags"
selectedEntries={tagKeys || []}
onSelectedEntriesChanged={(tags) => {
entry.statsSelection.tags = tags.map((tag) => tag.tag.key);
setEntry(entry);
}}
createTags={false}
onlySelectKeys
removeWhenClicked
/>
</>
);
Expand Down
25 changes: 25 additions & 0 deletions ui/src/tag/FormTagSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box mt={1}>
<FormControl fullWidth required>
<Box pt={2}>
<InputLabel shrink> {label} </InputLabel>
<Box className="MuiInput-underline">
<TagSelector {...props} />
</Box>
</Box>
</FormControl>
</Box>
);
};
13 changes: 9 additions & 4 deletions ui/src/tag/TagKeySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {useQuery} from '@apollo/react-hooks';
import {Tags} from '../gql/__generated__/Tags';
import * as gqlTags from '../gql/tags';
import {useSuggest} from './suggest';
import {toTagSelectorEntry} from './tagSelectorEntry';

const useStyles = makeStyles((theme: Theme) => ({
root: {
Expand Down Expand Up @@ -49,14 +50,18 @@ export const TagKeySelector: React.FC<TagKeySelectorProps> = ({value: selectedIt
const [inputValue, setInputValue] = React.useState('');

const tagsResult = useQuery<Tags>(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;
}

const selectedItems = toTagSelectorEntry(
tagsResult.data.tags,
selectedItem.map((i) => ({key: i, value: ''}))
);
const suggestions = useSuggest(tagsResult, inputValue, selectedItems, true)
.filter((t) => !t.tag.create && !t.tag.alreadyUsed)
.map((t) => t.tag.key);

function handleKeyDown(event: React.KeyboardEvent) {
if (selectedItem.length && !inputValue.length && event.key === 'Backspace') {
onChange(selectedItem.slice(0, selectedItem.length - 1));
Expand Down
90 changes: 69 additions & 21 deletions ui/src/tag/TagSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,45 @@ 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),
left: 0,
right: 0,
},
}));

export interface TagSelectorProps {
onSelectedEntriesChanged: (entries: TagSelectorEntry[]) => void;
selectedEntries: TagSelectorEntry[];
dialogOpen?: React.Dispatch<React.SetStateAction<boolean>>;
onCtrlEnter?: () => void;
createTags?: boolean;
allowDuplicateKeys?: boolean;
onlySelectKeys?: boolean;
removeWhenClicked?: boolean;
}

export const TagSelector: React.FC<TagSelectorProps> = ({
selectedEntries,
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('');
Expand All @@ -37,11 +62,15 @@ export const TagSelector: React.FC<TagSelectorProps> = ({
const container = React.useRef<null | HTMLDivElement>(null);

const tagsResult = useQuery<Tags>(gqlTags.Tags);
Comment thread
spl3g marked this conversation as resolved.

const suggestions = useSuggest(
Comment thread
spl3g marked this conversation as resolved.
Outdated
tagsResult,
currentValue,
selectedEntries.map((t) => t.tag.key)
);
selectedEntries,
onlySelectKeys,
allowDuplicateKeys,
createTags
).filter((t) => (createTags || !t.tag.create) && (!allowDuplicateKeys || !t.tag.alreadyUsed));
Comment thread
spl3g marked this conversation as resolved.
Outdated

if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) {
return null;
Expand Down Expand Up @@ -91,7 +120,7 @@ export const TagSelector: React.FC<TagSelectorProps> = ({

focusInput();

if (!entry.value) {
if (!onlySelectKeys && !entry.value) {
const newValue = entry.tag.key + ':';
if (currentValue !== newValue) {
setHighlightedIndex(0);
Expand All @@ -107,6 +136,16 @@ export const TagSelector: React.FC<TagSelectorProps> = ({
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();
Expand Down Expand Up @@ -134,11 +173,14 @@ export const TagSelector: React.FC<TagSelectorProps> = ({
event.preventDefault();
setOpen(false);
}
if (event.key === 'Tab') {
setOpen(false);
}
};

return (
<ClickAwayListener onClickAway={() => setOpen(false)}>
<div style={{width: '100%'}}>
<div className={classes.root}>
<Tooltip
disableFocusListener
disableHoverListener
Expand All @@ -150,11 +192,8 @@ export const TagSelector: React.FC<TagSelectorProps> = ({
{tooltipError}
</Typography>
}>
<div
ref={(ref) => (container.current = ref)}
style={{display: 'flex', flexWrap: 'wrap', cursor: 'text', width: '100%'}}
onClick={focusInput}>
{toChips(selectedEntries)}
<div ref={(ref) => (container.current = ref)} className={classes.inputRoot} onClick={focusInput}>
{toChips(selectedEntries, onlySelectKeys, onTagClicked)}
<Input
margin="none"
value={currentValue}
Expand All @@ -164,20 +203,21 @@ export const TagSelector: React.FC<TagSelectorProps> = ({
disableUnderline={true}
onChange={(e) => setCurrentValue(e.target.value)}
placeholder="Enter Tags"
style={{height: 40, minWidth: 150, flexGrow: 1}}
className={classes.inputInput}
/>
</div>
</Tooltip>

{open ? (
<Paper
style={{
position: 'absolute',
width: (container.current && container.current.clientWidth) || 300,
zIndex: 1000,
}}>
<Paper className={classes.paper} square>
{suggestions.map((entry, index) => (
<Item key={label(entry)} entry={entry} onClick={trySubmit} selected={index === highlightedIndex} />
<Item
key={label(entry)}
entry={entry}
onClick={trySubmit}
selected={index === highlightedIndex}
onlySelectKeys={onlySelectKeys}
/>
))}
</Paper>
) : null}
Expand All @@ -200,10 +240,11 @@ export const TagSelector: React.FC<TagSelectorProps> = ({
interface ItemProps {
entry: TagSelectorEntry;
selected: boolean;
onlySelectKeys: boolean;
onClick: (entry: TagSelectorEntry) => void;
}

const Item: React.FC<ItemProps> = ({entry, selected, onClick}) => {
const Item: React.FC<ItemProps> = ({entry, selected, onlySelectKeys, onClick}) => {
return (
<MenuItem
key={entry.tag.key}
Expand All @@ -214,11 +255,18 @@ const Item: React.FC<ItemProps> = ({entry, selected, onClick}) => {
style={{
fontWeight: selected ? 500 : 400,
}}>
{itemLabel(entry)}
{itemLabel(entry, onlySelectKeys)}
</MenuItem>
);
};

const toChips = (entries: TagSelectorEntry[]) => {
return entries.map((entry) => <TagChip key={label(entry)} label={label(entry)} color={entry.tag.color} />);
const toChips = (entries: TagSelectorEntry[], onlySelectKeys: boolean, onClick: (entry: TagSelectorEntry) => void) => {
return entries.map((entry) => (
<TagChip
key={itemLabel(entry, onlySelectKeys)}
label={itemLabel(entry, onlySelectKeys)}
color={entry.tag.color}
onClick={() => onClick(entry)}
/>
));
};
30 changes: 23 additions & 7 deletions ui/src/tag/suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {QueryResult} from 'react-apollo';
export const useSuggest = (
Comment thread
jmattheis marked this conversation as resolved.
tagResult: QueryResult<Tags, {}>,
inputValue: string,
usedTags: string[],
skipValue = false
usedTags: TagSelectorEntry[],
skipValue = false,
allowDuplicateKeys = false,
includeInputValueOnNoMatch = true
): TagSelectorEntry[] => {
const [tagKeySomeCase, tagValue] = inputValue.split(':');
const tagKey = tagKeySomeCase.toLowerCase();
Expand All @@ -21,10 +23,17 @@ export const useSuggest = (
skip: exactMatch === undefined || skipValue,
});

if (exactMatch && tagValue !== undefined && usedTags.indexOf(exactMatch.key) === -1 && !skipValue) {
return suggestTagValue(exactMatch, tagValue, valueResult);
let usedKeys: string[] = [];
if (!allowDuplicateKeys) {
usedKeys = usedTags.map((t) => t.tag.key);
}
Comment thread
spl3g marked this conversation as resolved.
Outdated

const usedValues = usedTags.map((t) => t.value);

if (exactMatch && tagValue !== undefined && usedKeys.indexOf(exactMatch.key) === -1 && !skipValue) {
return suggestTagValue(exactMatch, tagValue, valueResult, usedValues, includeInputValueOnNoMatch);
} else {
return suggestTag(exactMatch, tagResult, tagKey, usedTags);
return suggestTag(exactMatch, tagResult, tagKey, usedKeys);
}
};

Expand Down Expand Up @@ -59,13 +68,20 @@ const suggestTag = (
const suggestTagValue = (
exactMatch: TagSelectorEntry['tag'],
tagValue: string,
valueResult: QueryResult<SuggestTagValue, SuggestTagValueVariables>
valueResult: QueryResult<SuggestTagValue, SuggestTagValueVariables>,
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];
}

someValues = someValues.filter((val) => usedValues.indexOf(val) === -1);
if (someValues.length === 0 && !includeInputValueOnNoMatch) {
return [{tag: specialTag(exactMatch.key, 'no_values'), value: ''}];
}

return someValues.map((val) => ({tag: exactMatch, value: val}));
};
Loading
Loading