From df71704174f7bee1a1dbf107a7cdfe49a83f4ec9 Mon Sep 17 00:00:00 2001 From: spl3g Date: Mon, 24 Nov 2025 17:41:36 +0300 Subject: [PATCH 01/18] feat: expand the capabilities of TagSelector --- ui/src/tag/TagSelector.tsx | 65 ++++++++++++++++++++++++++------------ ui/src/tag/suggest.ts | 10 +++--- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 019e16f6..7fb910d9 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -14,12 +14,31 @@ 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>; onCtrlEnter?: () => void; + createTags?: boolean; + allowDuplicateTags?: boolean; + onlySelectKeys?: boolean; } export const TagSelector: React.FC = ({ @@ -27,7 +46,11 @@ export const TagSelector: React.FC = ({ onSelectedEntriesChanged: setSelectedEntries, dialogOpen = () => {}, onCtrlEnter, + createTags = true, + allowDuplicateTags = false, + onlySelectKeys = false, }) => { + const classes = useStyles(); const [tooltipErrorActive, tooltipError, showTooltipError] = useError(4000); const [open, setOpen] = React.useState(false); const [currentValue, setCurrentValueInternal] = React.useState(''); @@ -37,10 +60,14 @@ 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) + + let usedKeys: string[] = []; + if (!allowDuplicateTags) { + usedKeys = selectedEntries.map((t) => t.tag.key); + } + + const suggestions = useSuggest(tagsResult, currentValue, usedKeys, false, createTags).filter( + (t) => (createTags || !t.tag.create) && (!allowDuplicateTags || !t.tag.alreadyUsed) ); if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { @@ -91,7 +118,7 @@ export const TagSelector: React.FC = ({ focusInput(); - if (!entry.value) { + if (!onlySelectKeys && !entry.value) { const newValue = entry.tag.key + ':'; if (currentValue !== newValue) { setHighlightedIndex(0); @@ -138,7 +165,7 @@ export const TagSelector: React.FC = ({ 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)} = ({ disableUnderline={true} onChange={(e) => setCurrentValue(e.target.value)} placeholder="Enter Tags" - style={{height: 40, minWidth: 150, flexGrow: 1}} + className={classes.inputInput} />
{open ? ( - + {suggestions.map((entry, index) => ( ))} @@ -219,6 +238,12 @@ const Item: React.FC = ({entry, selected, onClick}) => { ); }; -const toChips = (entries: TagSelectorEntry[]) => { - return entries.map((entry) => ); +const toChips = (entries: TagSelectorEntry[], onlySelectKeys: boolean) => { + return entries.map((entry) => ( + + )); }; diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts index 31a6f823..1c0eab41 100644 --- a/ui/src/tag/suggest.ts +++ b/ui/src/tag/suggest.ts @@ -9,7 +9,8 @@ export const useSuggest = ( tagResult: QueryResult, inputValue: string, usedTags: string[], - skipValue = false + skipValue = false, + includeInputValueOnNoMatch = true ): TagSelectorEntry[] => { const [tagKeySomeCase, tagValue] = inputValue.split(':'); const tagKey = tagKeySomeCase.toLowerCase(); @@ -22,7 +23,7 @@ export const useSuggest = ( }); if (exactMatch && tagValue !== undefined && usedTags.indexOf(exactMatch.key) === -1 && !skipValue) { - return suggestTagValue(exactMatch, tagValue, valueResult); + return suggestTagValue(exactMatch, tagValue, valueResult, includeInputValueOnNoMatch); } else { return suggestTag(exactMatch, tagResult, tagKey, usedTags); } @@ -59,11 +60,12 @@ const suggestTag = ( const suggestTagValue = ( exactMatch: TagSelectorEntry['tag'], tagValue: string, - valueResult: QueryResult + valueResult: QueryResult, + includeInputValueOnNoMatch: boolean ): TagSelectorEntry[] => { let someValues = (valueResult.data && valueResult.data.values) || []; - if (someValues.indexOf(tagValue) === -1) { + if (includeInputValueOnNoMatch && someValues.indexOf(tagValue) === -1) { someValues = [tagValue, ...someValues]; } From aecd1c8ecc117f723e1a5bc1550c68ae41c7310f Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 25 Nov 2025 12:15:04 +0300 Subject: [PATCH 02/18] fix: close the paper when tabbing to the next field --- ui/src/tag/TagSelector.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 7fb910d9..938387d8 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -161,6 +161,9 @@ export const TagSelector: React.FC = ({ event.preventDefault(); setOpen(false); } + if (event.key === 'Tab') { + setOpen(false); + } }; return ( From b155bfe76aec23b314be0c9819cf87fbaebe4fe4 Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 25 Nov 2025 12:20:12 +0300 Subject: [PATCH 03/18] feat: make TagChip use makeStyles --- ui/src/common/TagChip.tsx | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) 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 ( ); }; From 3ae064b3600c15c75ca698ffffa700f0ed87878f Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 25 Nov 2025 12:22:48 +0300 Subject: [PATCH 04/18] feat: allow duplicate keys in useSuggest --- ui/src/tag/TagKeySelector.tsx | 13 +++++++++---- ui/src/tag/TagSelector.tsx | 16 ++++++++-------- ui/src/tag/suggest.ts | 22 ++++++++++++++++++---- ui/src/tag/tagSelectorEntry.ts | 13 ++++++++++--- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/ui/src/tag/TagKeySelector.tsx b/ui/src/tag/TagKeySelector.tsx index ab4b3f39..0db431af 100644 --- a/ui/src/tag/TagKeySelector.tsx +++ b/ui/src/tag/TagKeySelector.tsx @@ -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: { @@ -49,14 +50,18 @@ export const TagKeySelector: React.FC = ({value: selectedIt 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; } + 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)); diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 938387d8..2e823121 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -61,14 +61,14 @@ export const TagSelector: React.FC = ({ const tagsResult = useQuery(gqlTags.Tags); - let usedKeys: string[] = []; - if (!allowDuplicateTags) { - usedKeys = selectedEntries.map((t) => t.tag.key); - } - - const suggestions = useSuggest(tagsResult, currentValue, usedKeys, false, createTags).filter( - (t) => (createTags || !t.tag.create) && (!allowDuplicateTags || !t.tag.alreadyUsed) - ); + let suggestions = useSuggest( + tagsResult, + currentValue, + selectedEntries, + onlySelectKeys, + allowDuplicateKeys, + createTags + ).filter((t) => (createTags || !t.tag.create) && (!allowDuplicateKeys || !t.tag.alreadyUsed)); if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { return null; diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts index 1c0eab41..fab4e590 100644 --- a/ui/src/tag/suggest.ts +++ b/ui/src/tag/suggest.ts @@ -8,8 +8,9 @@ import {QueryResult} from 'react-apollo'; export const useSuggest = ( tagResult: QueryResult, inputValue: string, - usedTags: string[], + usedTags: TagSelectorEntry[], skipValue = false, + allowDuplicateKeys = false, includeInputValueOnNoMatch = true ): TagSelectorEntry[] => { const [tagKeySomeCase, tagValue] = inputValue.split(':'); @@ -22,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, includeInputValueOnNoMatch); + let usedKeys: string[] = []; + if (!allowDuplicateKeys) { + usedKeys = usedTags.map((t) => t.tag.key); + } + + 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); } }; @@ -61,6 +69,7 @@ const suggestTagValue = ( exactMatch: TagSelectorEntry['tag'], tagValue: string, valueResult: QueryResult, + usedValues: string[], includeInputValueOnNoMatch: boolean ): TagSelectorEntry[] => { let someValues = (valueResult.data && valueResult.data.values) || []; @@ -69,5 +78,10 @@ const suggestTagValue = ( 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})); }; diff --git a/ui/src/tag/tagSelectorEntry.ts b/ui/src/tag/tagSelectorEntry.ts index 251f4468..7014ad6e 100644 --- a/ui/src/tag/tagSelectorEntry.ts +++ b/ui/src/tag/tagSelectorEntry.ts @@ -7,7 +7,7 @@ export interface TagInputError { } export interface TagSelectorEntry { - tag: Omit & {create?: boolean; alreadyUsed?: boolean}; + tag: Omit & {create?: boolean; alreadyUsed?: boolean; noValues?: boolean}; value: string; } @@ -32,13 +32,14 @@ export const toTagSelectorEntry = (tags: Array, entries ); }; -export const specialTag = (name: string, state: 'used' | 'new'): TagSelectorEntry['tag'] & {usages: 0} => { +export const specialTag = (name: string, state: 'used' | 'new' | 'no_values'): TagSelectorEntry['tag'] & {usages: 0} => { return { key: name, __typename: 'TagDefinition', color: 'gray', create: state === 'new', alreadyUsed: state === 'used', + noValues: state === 'no_values', usages: 0, }; }; @@ -95,13 +96,19 @@ export const addValues = ( .reduce(groupAndCheckExistence, {errors: [], entries: [], usedTags: selectedEntries.map((entry) => entry.tag.key)}); }; -export const itemLabel = (tag: TagSelectorEntry) => { +export const itemLabel = (tag: TagSelectorEntry, onlyShowKey: boolean = 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 `Tag '${tag.tag.key}' already has it's all values used`; + } + if (onlyShowKey) { + return tag.tag.key; + } return label(tag); }; From 5b24d4273921a276e7e75e9f91f8b0619090d615 Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 25 Nov 2025 12:42:45 +0300 Subject: [PATCH 05/18] feat: improve TagSelector UX --- ui/src/tag/TagSelector.tsx | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 2e823121..23c113b5 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -37,8 +37,9 @@ export interface TagSelectorProps { dialogOpen?: React.Dispatch>; onCtrlEnter?: () => void; createTags?: boolean; - allowDuplicateTags?: boolean; + allowDuplicateKeys?: boolean; onlySelectKeys?: boolean; + removeWhenClicked?: boolean; } export const TagSelector: React.FC = ({ @@ -47,8 +48,9 @@ export const TagSelector: React.FC = ({ dialogOpen = () => {}, onCtrlEnter, createTags = true, - allowDuplicateTags = false, + allowDuplicateKeys = false, onlySelectKeys = false, + removeWhenClicked = false, }) => { const classes = useStyles(); const [tooltipErrorActive, tooltipError, showTooltipError] = useError(4000); @@ -134,6 +136,16 @@ 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(); @@ -181,7 +193,7 @@ export const TagSelector: React.FC = ({ }>
(container.current = ref)} className={classes.inputRoot} onClick={focusInput}> - {toChips(selectedEntries, onlySelectKeys)} + {toChips(selectedEntries, onlySelectKeys, onTagClicked)} = ({ {open ? ( {suggestions.map((entry, index) => ( - + ))} ) : null} @@ -222,10 +240,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[], onlySelectKeys: boolean) => { +const toChips = (entries: TagSelectorEntry[], onlySelectKeys: boolean, onClick: (entry: TagSelectorEntry) => void) => { return entries.map((entry) => ( onClick(entry)} /> )); }; From 6530be6df8078e11abb25437d6b41fa6b27ae7b4 Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 25 Nov 2025 12:44:39 +0300 Subject: [PATCH 06/18] feat: replace TagKeySelector with TagSelector for Tags --- ui/src/dashboard/Entry/DashboardEntryForm.tsx | 27 ++++++++++++++----- ui/src/tag/FormTagSelector.tsx | 25 +++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 ui/src/tag/FormTagSelector.tsx 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} + + + + + + + ); +}; From 839b615f5b0ea28a154b3e990f385e63c8bd84f5 Mon Sep 17 00:00:00 2001 From: spl3g Date: Tue, 25 Nov 2025 12:51:23 +0300 Subject: [PATCH 07/18] fix: follow linter suggestions --- ui/src/tag/TagSelector.tsx | 2 +- ui/src/tag/tagSelectorEntry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 23c113b5..82a42eb0 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -63,7 +63,7 @@ export const TagSelector: React.FC = ({ const tagsResult = useQuery(gqlTags.Tags); - let suggestions = useSuggest( + const suggestions = useSuggest( tagsResult, currentValue, selectedEntries, diff --git a/ui/src/tag/tagSelectorEntry.ts b/ui/src/tag/tagSelectorEntry.ts index 7014ad6e..73f3a512 100644 --- a/ui/src/tag/tagSelectorEntry.ts +++ b/ui/src/tag/tagSelectorEntry.ts @@ -96,7 +96,7 @@ export const addValues = ( .reduce(groupAndCheckExistence, {errors: [], entries: [], usedTags: selectedEntries.map((entry) => entry.tag.key)}); }; -export const itemLabel = (tag: TagSelectorEntry, onlyShowKey: boolean = false) => { +export const itemLabel = (tag: TagSelectorEntry, onlyShowKey = false) => { if (tag.tag.create) { return `Create tag '${tag.tag.key}'`; } From 939b951f7c9cf0d639b0dced02ca0976ebeaf034 Mon Sep 17 00:00:00 2001 From: Ozornin Matvey <58591608+spl3g@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:58:08 +0300 Subject: [PATCH 08/18] fix: improve wording of the noValues label Co-authored-by: Jannis Mattheis --- ui/src/tag/tagSelectorEntry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/tag/tagSelectorEntry.ts b/ui/src/tag/tagSelectorEntry.ts index 73f3a512..419274bb 100644 --- a/ui/src/tag/tagSelectorEntry.ts +++ b/ui/src/tag/tagSelectorEntry.ts @@ -104,7 +104,7 @@ export const itemLabel = (tag: TagSelectorEntry, onlyShowKey = false) => { return `Tag '${tag.tag.key}' is already defined`; } if (tag.tag.noValues) { - return `Tag '${tag.tag.key}' already has it's all values used`; + return `All values of tag '${tag.tag.key}' are used`; } if (onlyShowKey) { return tag.tag.key; From f3ccab048e7224f1e930677ab8531700bc50a4db Mon Sep 17 00:00:00 2001 From: spl3g Date: Fri, 5 Dec 2025 11:45:43 +0300 Subject: [PATCH 09/18] feat: remove TagKeySelector --- ui/src/tag/TagKeySelector.tsx | 151 ---------------------------------- 1 file changed, 151 deletions(-) delete mode 100644 ui/src/tag/TagKeySelector.tsx diff --git a/ui/src/tag/TagKeySelector.tsx b/ui/src/tag/TagKeySelector.tsx deleted file mode 100644 index 0db431af..00000000 --- a/ui/src/tag/TagKeySelector.tsx +++ /dev/null @@ -1,151 +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'; -import {toTagSelectorEntry} from './tagSelectorEntry'; - -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); - 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)); - } - } - - 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} -
- ); - }} -
- ); -}; From 5b25aece976f13786a9ea09318a762bd9e47e0d5 Mon Sep 17 00:00:00 2001 From: spl3g Date: Fri, 5 Dec 2025 11:56:19 +0300 Subject: [PATCH 10/18] fix: move special tag filtering into useSuggest --- ui/src/tag/TagSelector.tsx | 9 +-------- ui/src/tag/suggest.ts | 25 ++++++++++++------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 82a42eb0..a71c24ec 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -63,14 +63,7 @@ export const TagSelector: React.FC = ({ const tagsResult = useQuery(gqlTags.Tags); - const suggestions = useSuggest( - tagsResult, - currentValue, - selectedEntries, - onlySelectKeys, - allowDuplicateKeys, - createTags - ).filter((t) => (createTags || !t.tag.create) && (!allowDuplicateKeys || !t.tag.alreadyUsed)); + const suggestions = useSuggest(tagsResult, currentValue, selectedEntries, onlySelectKeys, allowDuplicateKeys, createTags); if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) { return null; diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts index fab4e590..e7aa45c5 100644 --- a/ui/src/tag/suggest.ts +++ b/ui/src/tag/suggest.ts @@ -11,7 +11,7 @@ export const useSuggest = ( usedTags: TagSelectorEntry[], skipValue = false, allowDuplicateKeys = false, - includeInputValueOnNoMatch = true + createTags = true ): TagSelectorEntry[] => { const [tagKeySomeCase, tagValue] = inputValue.split(':'); const tagKey = tagKeySomeCase.toLowerCase(); @@ -23,17 +23,13 @@ export const useSuggest = ( skip: exactMatch === undefined || skipValue, }); - let usedKeys: string[] = []; - if (!allowDuplicateKeys) { - usedKeys = usedTags.map((t) => t.tag.key); - } - + const usedKeys = usedTags.map((t) => t.tag.key); const usedValues = usedTags.map((t) => t.value); - if (exactMatch && tagValue !== undefined && usedKeys.indexOf(exactMatch.key) === -1 && !skipValue) { - return suggestTagValue(exactMatch, tagValue, valueResult, usedValues, includeInputValueOnNoMatch); + if (exactMatch && tagValue !== undefined && !skipValue && (allowDuplicateKeys || usedKeys.indexOf(exactMatch.key) === -1)) { + return suggestTagValue(exactMatch, tagValue, valueResult, usedValues, createTags); } else { - return suggestTag(exactMatch, tagResult, tagKey, usedKeys); + return suggestTag(exactMatch, tagResult, tagKey, usedKeys, allowDuplicateKeys, createTags); } }; @@ -41,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]; } @@ -79,6 +77,7 @@ const suggestTagValue = ( } someValues = someValues.filter((val) => usedValues.indexOf(val) === -1); + console.log({someValues}); if (someValues.length === 0 && !includeInputValueOnNoMatch) { return [{tag: specialTag(exactMatch.key, 'no_values'), value: ''}]; } From 7ab285a6173f08b68a3bfe087de4a8698f31ecee Mon Sep 17 00:00:00 2001 From: spl3g Date: Fri, 5 Dec 2025 12:15:46 +0300 Subject: [PATCH 11/18] fix: show the appropriate label after pressing backspace --- ui/src/tag/TagSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index a71c24ec..9c925b60 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -144,7 +144,7 @@ export const TagSelector: React.FC = ({ 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(); From c75adda5fb91f40e1594510e2c3b09d26087b900 Mon Sep 17 00:00:00 2001 From: spl3g Date: Fri, 5 Dec 2025 12:30:03 +0300 Subject: [PATCH 12/18] feat: handle spaces --- ui/src/tag/TagSelector.tsx | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 9c925b60..95fbf8c5 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -69,13 +69,8 @@ export const TagSelector: React.FC = ({ return null; } - const setCurrentValue = (newValue: string) => { - if (currentValue.indexOf(' ') !== -1) { - throw new Error('old value should never contain a space'); - } - setHighlightedIndex(0); - - if (newValue.indexOf(' ') !== -1) { + const handleSpace = (newValue: string) => { + if (createTags) { const {errors, entries} = addValues(newValue, tagsResult, selectedEntries); setSelectedEntries([...selectedEntries, ...entries]); @@ -90,6 +85,22 @@ export const TagSelector: React.FC = ({ } else { setCurrentValueInternal(''); } + } else { + const matchIndex = suggestions.findIndex((sugg) => itemLabel(sugg, onlySelectKeys) === currentValue); + if (matchIndex !== -1) { + trySubmit(suggestions[matchIndex]); + } + } + }; + + const setCurrentValue = (newValue: string) => { + if (currentValue.indexOf(' ') !== -1) { + throw new Error('old value should never contain a space'); + } + setHighlightedIndex(0); + + if (newValue.indexOf(' ') !== -1) { + handleSpace(newValue); return; } From d8eb82f7f5f98cad730f4998b10fa7464c8d7f29 Mon Sep 17 00:00:00 2001 From: spl3g Date: Fri, 5 Dec 2025 12:47:31 +0300 Subject: [PATCH 13/18] feat: remove debug prints --- ui/src/tag/suggest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts index e7aa45c5..e4f563a3 100644 --- a/ui/src/tag/suggest.ts +++ b/ui/src/tag/suggest.ts @@ -77,7 +77,6 @@ const suggestTagValue = ( } someValues = someValues.filter((val) => usedValues.indexOf(val) === -1); - console.log({someValues}); if (someValues.length === 0 && !includeInputValueOnNoMatch) { return [{tag: specialTag(exactMatch.key, 'no_values'), value: ''}]; } From 50d58e5a17b4e617bf83a3a2d8212b4d61bbc67f Mon Sep 17 00:00:00 2001 From: spl3g Date: Mon, 8 Dec 2025 21:51:51 +0300 Subject: [PATCH 14/18] fix: set the suggestions width to the current container width --- ui/src/tag/TagSelector.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 95fbf8c5..6e18740d 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -26,8 +26,6 @@ const useStyles = makeStyles((theme: Theme) => ({ position: 'absolute', zIndex: 1, marginTop: theme.spacing(1), - left: 0, - right: 0, }, })); @@ -213,7 +211,10 @@ export const TagSelector: React.FC = ({ {open ? ( - + {suggestions.map((entry, index) => ( Date: Fri, 12 Dec 2025 21:12:41 +0300 Subject: [PATCH 15/18] feat: add an error for when value wasn't found --- ui/src/tag/suggest.ts | 6 +++++- ui/src/tag/tagSelectorEntry.ts | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ui/src/tag/suggest.ts b/ui/src/tag/suggest.ts index e4f563a3..c1992dff 100644 --- a/ui/src/tag/suggest.ts +++ b/ui/src/tag/suggest.ts @@ -76,9 +76,13 @@ const suggestTagValue = ( 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, 'no_values'), value: ''}]; + 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 419274bb..a0b93ad8 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; noValues?: boolean}; + tag: Omit & SpecialTag; value: string; } @@ -32,7 +40,7 @@ export const toTagSelectorEntry = (tags: Array, entries ); }; -export const specialTag = (name: string, state: 'used' | 'new' | 'no_values'): TagSelectorEntry['tag'] & {usages: 0} => { +export const specialTag = (name: string, state: SpecialTagState): TagSelectorEntry['tag'] & {usages: 0} => { return { key: name, __typename: 'TagDefinition', @@ -40,6 +48,7 @@ export const specialTag = (name: string, state: 'used' | 'new' | 'no_values'): T create: state === 'new', alreadyUsed: state === 'used', noValues: state === 'no_values', + allValuesUsed: state == 'all_values_used', usages: 0, }; }; @@ -104,6 +113,9 @@ export const itemLabel = (tag: TagSelectorEntry, onlyShowKey = false) => { 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) { From 4b39359bf233e397c697683516db156c444a3cbc Mon Sep 17 00:00:00 2001 From: spl3g Date: Fri, 12 Dec 2025 21:13:44 +0300 Subject: [PATCH 16/18] feat: add support for adding just keys in addValues --- ui/src/tag/TagSelector.tsx | 28 +++++++--------------- ui/src/tag/tagSelectorEntry.ts | 44 +++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index 6e18740d..cfc60eef 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -67,9 +67,15 @@ export const TagSelector: React.FC = ({ return null; } - const handleSpace = (newValue: string) => { - if (createTags) { - const {errors, entries} = addValues(newValue, tagsResult, selectedEntries); + const setCurrentValue = (newValue: string) => { + if (currentValue.indexOf(' ') !== -1) { + throw new Error('old value should never contain a space'); + } + setHighlightedIndex(0); + + if (newValue.indexOf(' ') !== -1) { + const {errors, entries} = addValues(newValue, tagsResult, selectedEntries, onlySelectKeys, allowDuplicateKeys); + console.log({errors, entries}); setSelectedEntries([...selectedEntries, ...entries]); @@ -83,22 +89,6 @@ export const TagSelector: React.FC = ({ } else { setCurrentValueInternal(''); } - } else { - const matchIndex = suggestions.findIndex((sugg) => itemLabel(sugg, onlySelectKeys) === currentValue); - if (matchIndex !== -1) { - trySubmit(suggestions[matchIndex]); - } - } - }; - - const setCurrentValue = (newValue: string) => { - if (currentValue.indexOf(' ') !== -1) { - throw new Error('old value should never contain a space'); - } - setHighlightedIndex(0); - - if (newValue.indexOf(' ') !== -1) { - handleSpace(newValue); return; } diff --git a/ui/src/tag/tagSelectorEntry.ts b/ui/src/tag/tagSelectorEntry.ts index a0b93ad8..18a37db8 100644 --- a/ui/src/tag/tagSelectorEntry.ts +++ b/ui/src/tag/tagSelectorEntry.ts @@ -53,7 +53,7 @@ export const specialTag = (name: string, state: SpecialTagState): TagSelectorEnt }; }; -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(); @@ -67,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[]; @@ -79,30 +83,38 @@ 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, onlyShowKey = false) => { From 32f7b0422e861ae9885db94f9cd72ad849ea6868 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 14 Dec 2025 21:38:03 +0100 Subject: [PATCH 17/18] fix: remove debug print --- ui/src/tag/TagSelector.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/tag/TagSelector.tsx b/ui/src/tag/TagSelector.tsx index cfc60eef..74d2319e 100644 --- a/ui/src/tag/TagSelector.tsx +++ b/ui/src/tag/TagSelector.tsx @@ -75,7 +75,6 @@ export const TagSelector: React.FC = ({ if (newValue.indexOf(' ') !== -1) { const {errors, entries} = addValues(newValue, tagsResult, selectedEntries, onlySelectKeys, allowDuplicateKeys); - console.log({errors, entries}); setSelectedEntries([...selectedEntries, ...entries]); From fd3db1fa0c3cc1b54abbe2e0443f882b70d543c0 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 14 Dec 2025 21:41:18 +0100 Subject: [PATCH 18/18] fix: linting --- ui/src/tag/tagSelectorEntry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/tag/tagSelectorEntry.ts b/ui/src/tag/tagSelectorEntry.ts index 18a37db8..b7ceb927 100644 --- a/ui/src/tag/tagSelectorEntry.ts +++ b/ui/src/tag/tagSelectorEntry.ts @@ -48,7 +48,7 @@ export const specialTag = (name: string, state: SpecialTagState): TagSelectorEnt create: state === 'new', alreadyUsed: state === 'used', noValues: state === 'no_values', - allValuesUsed: state == 'all_values_used', + allValuesUsed: state === 'all_values_used', usages: 0, }; };