Skip to content

Commit f7c253f

Browse files
authored
colored tags search filter list (#1058)
1 parent 36c5293 commit f7c253f

File tree

7 files changed

+352
-31
lines changed

7 files changed

+352
-31
lines changed

frontend/apps/ui/src/features/search/__tests__/microcomp/scanSearchText.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import {describe, it, expect} from "vitest"
21
import {scanSearchText} from "@/features/search/microcomp/scanner"
2+
import {describe, expect, it} from "vitest"
3+
import {FILTERS} from "../../microcomp/const"
34

45
describe("scanSearchText", () => {
56
//----------------------------------------------
@@ -66,17 +67,22 @@ describe("scanSearchText", () => {
6667
})
6768

6869
//----------------------------------------------
69-
it("should not suggest tag completion if there is no comma at the end - with spaces", () => {
70-
const input = "some text tag:invoice " // no comma, however there are some spaces
70+
it("should return complete token if there one space at the end", () => {
71+
const input = "some text tag:invoice " // one space at the end
7172
const result = scanSearchText(input)
7273

7374
expect(result.hasSuggestions).toBe(true)
75+
expect(result.tokenIsComplete).toBe(true)
76+
expect(result.token).toEqual({
77+
type: "tag",
78+
operator: "all",
79+
values: ["invoice"]
80+
})
81+
7482
expect(result.suggestions).toEqual([
75-
{type: "operator", items: []},
7683
{
77-
type: "tag",
78-
exclude: [],
79-
filter: "invoice"
84+
type: "filter",
85+
items: FILTERS.sort()
8086
}
8187
])
8288
})

frontend/apps/ui/src/features/search/components/SearchTokens/TagToken/TagToken.container.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks"
22
import {TagOperator, TagToken} from "@/features/search/microcomp/types"
33
import {updateToken} from "@/features/search/storage/search"
44
import {TagTokenPresentation} from "./TagToken.presentation"
5+
import {useTagTokenLogic} from "./useTagToken"
56

67
interface TagTokenContainerProps {
78
index: number
@@ -11,6 +12,7 @@ export function TagTokenContainer({index}: TagTokenContainerProps) {
1112
const dispatch = useAppDispatch()
1213
const token = useAppSelector(state => state.search.tokens[index]) as TagToken
1314

15+
// Redux handlers
1416
const handleOperatorChange = (operator: TagOperator) => {
1517
dispatch(updateToken({index, updates: {operator}}))
1618
}
@@ -19,11 +21,28 @@ export function TagTokenContainer({index}: TagTokenContainerProps) {
1921
dispatch(updateToken({index, updates: {values}}))
2022
}
2123

24+
// Business logic hook
25+
const tagLogic = useTagTokenLogic({
26+
selectedTagNames: token.values || [],
27+
onValuesChange: handleValuesChange
28+
})
29+
2230
return (
2331
<TagTokenPresentation
2432
item={token}
2533
onOperatorChange={handleOperatorChange}
26-
onValuesChange={handleValuesChange}
34+
selectedTags={tagLogic.selectedTags}
35+
availableTags={tagLogic.availableTags}
36+
search={tagLogic.search}
37+
isLoading={tagLogic.isLoading}
38+
combobox={tagLogic.combobox}
39+
onValueSelect={tagLogic.handleValueSelect}
40+
onValueRemove={tagLogic.handleValueRemove}
41+
onSearchChange={tagLogic.handleSearchChange}
42+
onBackspace={tagLogic.handleBackspace}
43+
onToggleDropdown={tagLogic.toggleDropdown}
44+
onOpenDropdown={tagLogic.openDropdown}
45+
onCloseDropdown={tagLogic.closeDropdown}
2746
/>
2847
)
2948
}

frontend/apps/ui/src/features/search/components/SearchTokens/TagToken/TagToken.presentation.tsx

Lines changed: 187 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,76 @@
11
import {TagOperator, TagToken} from "@/features/search/microcomp/types"
2-
import {Box, Group, MultiSelect, Select, Text} from "@mantine/core"
2+
import {ColoredTag} from "@/types"
3+
import {
4+
Box,
5+
Combobox,
6+
Group,
7+
Pill,
8+
PillsInput,
9+
Select,
10+
Text,
11+
useCombobox
12+
} from "@mantine/core"
313
import styles from "./TagToken.module.css"
414

515
interface TagTokenPresentationProps {
616
item: TagToken
17+
selectedTags: ColoredTag[]
18+
availableTags: ColoredTag[]
719
onOperatorChange?: (operator: TagOperator) => void
8-
onValuesChange?: (values: string[]) => void
20+
search?: string
21+
isLoading?: boolean
22+
combobox?: ReturnType<typeof useCombobox>
23+
onValueSelect?: (tagName: string) => void
24+
onValueRemove?: (tagName: string) => void
25+
onSearchChange?: (value: string) => void
26+
onBackspace?: () => void
27+
onToggleDropdown?: () => void
28+
onOpenDropdown?: () => void
29+
onCloseDropdown?: () => void
930
}
1031

1132
export function TagTokenPresentation({
1233
item,
34+
selectedTags,
35+
availableTags,
1336
onOperatorChange,
14-
onValuesChange
37+
search = "",
38+
isLoading = false,
39+
combobox,
40+
onValueSelect,
41+
onValueRemove,
42+
onSearchChange,
43+
onBackspace,
44+
onToggleDropdown,
45+
onOpenDropdown,
46+
onCloseDropdown
1547
}: TagTokenPresentationProps) {
48+
// Fallback combobox for Storybook/testing
49+
const fallbackCombobox = useCombobox({
50+
onDropdownClose: () => fallbackCombobox.resetSelectedOption(),
51+
onDropdownOpen: () => fallbackCombobox.updateSelectedOptionIndex("active")
52+
})
53+
const comboboxStore = combobox || fallbackCombobox
54+
1655
return (
1756
<Box className={styles.tokenContainer} onClick={e => e.stopPropagation()}>
1857
<Group gap={0}>
1958
<Text c={"blue"}>tag:</Text>
2059
<TokenTagOperator item={item} onOperatorChange={onOperatorChange} />
21-
<TokenTagValues item={item} onValuesChange={onValuesChange} />
60+
<TokenTagValues
61+
selectedTags={selectedTags}
62+
availableTags={availableTags}
63+
search={search}
64+
isLoading={isLoading}
65+
combobox={comboboxStore}
66+
onValueSelect={onValueSelect}
67+
onValueRemove={onValueRemove}
68+
onSearchChange={onSearchChange}
69+
onBackspace={onBackspace}
70+
onToggleDropdown={onToggleDropdown}
71+
onOpenDropdown={onOpenDropdown}
72+
onCloseDropdown={onCloseDropdown}
73+
/>
2274
</Group>
2375
</Box>
2476
)
@@ -52,23 +104,140 @@ function TokenTagOperator({item, onOperatorChange}: TokenTagOperatorProps) {
52104
}
53105

54106
interface TokenTagValuesProps {
55-
item: TagToken
56-
onValuesChange?: (values: string[]) => void
107+
selectedTags: ColoredTag[]
108+
availableTags: ColoredTag[]
109+
search?: string
110+
isLoading?: boolean
111+
combobox?: ReturnType<typeof useCombobox>
112+
onValueSelect?: (tagName: string) => void
113+
onValueRemove?: (tagName: string) => void
114+
onSearchChange?: (value: string) => void
115+
onBackspace?: () => void
116+
onToggleDropdown?: () => void
117+
onOpenDropdown?: () => void
118+
onCloseDropdown?: () => void
57119
}
58120

59-
function TokenTagValues({item, onValuesChange}: TokenTagValuesProps) {
60-
const handleChange = (values: string[]) => {
61-
if (onValuesChange) {
62-
onValuesChange(values)
63-
}
64-
}
121+
function TokenTagValues({
122+
selectedTags,
123+
availableTags,
124+
search = "",
125+
isLoading = false,
126+
combobox,
127+
onValueSelect,
128+
onValueRemove,
129+
onSearchChange,
130+
onBackspace,
131+
onToggleDropdown,
132+
onOpenDropdown,
133+
onCloseDropdown
134+
}: TokenTagValuesProps) {
135+
// Fallback combobox for Storybook/testing
136+
const fallbackCombobox = useCombobox({
137+
onDropdownClose: () => fallbackCombobox.resetSelectedOption(),
138+
onDropdownOpen: () => fallbackCombobox.updateSelectedOptionIndex("active")
139+
})
140+
const comboboxStore = combobox || fallbackCombobox
141+
142+
// Render selected tag pills with colors
143+
const values = selectedTags.map(tag => (
144+
<Pill
145+
key={tag.name}
146+
withRemoveButton
147+
onRemove={() => onValueRemove?.(tag.name)}
148+
style={{
149+
backgroundColor: tag.bg_color,
150+
color: tag.fg_color
151+
}}
152+
>
153+
{tag.name}
154+
</Pill>
155+
))
65156

66157
return (
67-
<MultiSelect
68-
data={item.values || []}
69-
value={item.values || []}
70-
onChange={handleChange}
71-
onClick={e => e.stopPropagation()}
72-
/>
158+
<Combobox
159+
store={comboboxStore}
160+
onOptionSubmit={val => onValueSelect?.(val)}
161+
withinPortal={true}
162+
>
163+
<Combobox.DropdownTarget>
164+
<PillsInput
165+
pointer
166+
onClick={e => {
167+
e.stopPropagation()
168+
onToggleDropdown?.()
169+
}}
170+
size="sm"
171+
>
172+
<Pill.Group>
173+
{values}
174+
<Combobox.EventsTarget>
175+
<PillsInput.Field
176+
onFocus={() => onOpenDropdown?.()}
177+
onBlur={() => onCloseDropdown?.()}
178+
value={search}
179+
placeholder={
180+
selectedTags.length === 0 ? "Select tags" : undefined
181+
}
182+
onChange={event => onSearchChange?.(event.currentTarget.value)}
183+
onClick={e => e.stopPropagation()}
184+
style={{width: "80px", minWidth: "80px"}}
185+
/>
186+
</Combobox.EventsTarget>
187+
</Pill.Group>
188+
</PillsInput>
189+
</Combobox.DropdownTarget>
190+
191+
<Combobox.Dropdown
192+
onClick={e => e.stopPropagation()}
193+
style={{
194+
zIndex: 1000,
195+
position: "absolute"
196+
}}
197+
>
198+
<Combobox.Options>
199+
<TagOptionsList
200+
isLoading={isLoading}
201+
search={search}
202+
availableTags={availableTags}
203+
/>
204+
</Combobox.Options>
205+
</Combobox.Dropdown>
206+
</Combobox>
73207
)
74208
}
209+
210+
interface TagListArgs {
211+
isLoading: boolean
212+
search: string
213+
availableTags: ColoredTag[]
214+
}
215+
216+
function TagOptionsList({isLoading, search, availableTags}: TagListArgs) {
217+
if (isLoading) {
218+
return <Combobox.Empty>Loading tags...</Combobox.Empty>
219+
}
220+
221+
if (availableTags.length === 0) {
222+
return (
223+
<Combobox.Empty>
224+
{search ? "No tags found" : "All tags selected"}
225+
</Combobox.Empty>
226+
)
227+
}
228+
229+
return availableTags.map(tag => (
230+
<Combobox.Option value={tag.name} key={tag.name}>
231+
<Group gap="sm">
232+
<Pill
233+
style={{
234+
backgroundColor: tag.bg_color,
235+
color: tag.fg_color
236+
}}
237+
>
238+
{tag.name}
239+
</Pill>
240+
</Group>
241+
</Combobox.Option>
242+
))
243+
}

0 commit comments

Comments
 (0)