Skip to content

Commit 68535fb

Browse files
gosiexon-zenclaude
andcommitted
feat: add search to dropdown and multi-select fields
- Add search functionality to Tagger and MultiSelect fields - Create useFieldSearch hook to consolidate duplicated search logic - Add HighlightMatch component for search result highlighting - Add comprehensive tests for hook and components - Show full path for nested options in search results - Navigate to parent group after selecting nested option in Tagger - Reset to root group when focusing MultiSelect after search Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent a98c17b commit 68535fb

File tree

11 files changed

+1121
-57
lines changed

11 files changed

+1121
-57
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@
114114
},
115115
"resolutions": {
116116
"@types/react": "^17.x",
117-
"nwsapi": "^2.2.20"
117+
"nwsapi": "^2.2.20",
118+
"@zendeskgarden/container-utilities": "^1.0.0"
118119
},
119120
"commitlint": {
120121
"extends": [
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
interface HighlightMatchProps {
2+
text: string;
3+
searchValue: string;
4+
}
5+
6+
/**
7+
* Highlights the matching portion of text by making it bold.
8+
* Case-insensitive matching.
9+
*/
10+
export function HighlightMatch({
11+
text,
12+
searchValue,
13+
}: HighlightMatchProps): JSX.Element {
14+
if (!searchValue) {
15+
return <span>{text}</span>;
16+
}
17+
18+
const lowerText = text.toLowerCase();
19+
const lowerSearch = searchValue.toLowerCase();
20+
const index = lowerText.indexOf(lowerSearch);
21+
22+
if (index === -1) {
23+
return <span>{text}</span>;
24+
}
25+
26+
const before = text.slice(0, index);
27+
const match = text.slice(index, index + searchValue.length);
28+
const after = text.slice(index + searchValue.length);
29+
30+
return (
31+
<span>
32+
{before}
33+
<strong>{match}</strong>
34+
{after}
35+
</span>
36+
);
37+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { render } from "../../test/render";
2+
import { screen } from "@testing-library/react";
3+
import { MultiSelect } from "./MultiSelect";
4+
import type { TicketFieldObject } from "../data-types/TicketFieldObject";
5+
6+
describe("MultiSelect", () => {
7+
const mockOnChange = jest.fn();
8+
9+
const baseField: TicketFieldObject = {
10+
id: 1,
11+
name: "test_multiselect",
12+
label: "Test MultiSelect",
13+
type: "multiselect",
14+
value: [],
15+
error: null,
16+
required: false,
17+
description: "",
18+
options: [
19+
{ name: "Option 1", value: "option_1" },
20+
{ name: "Option 2", value: "option_2" },
21+
{ name: "Option 3", value: "option_3" },
22+
{ name: "Option 4", value: "option_4" },
23+
],
24+
};
25+
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
it("renders field with label", () => {
31+
render(<MultiSelect field={baseField} onChange={mockOnChange} />);
32+
33+
expect(screen.getByText("Test MultiSelect")).toBeInTheDocument();
34+
});
35+
36+
it("shows required indicator when field is required", () => {
37+
const requiredField = { ...baseField, required: true };
38+
render(<MultiSelect field={requiredField} onChange={mockOnChange} />);
39+
40+
expect(screen.getByText("*")).toBeInTheDocument();
41+
});
42+
43+
it("displays description when provided", () => {
44+
const fieldWithDescription = {
45+
...baseField,
46+
description: "<p>Select multiple options</p>",
47+
};
48+
render(
49+
<MultiSelect field={fieldWithDescription} onChange={mockOnChange} />
50+
);
51+
52+
expect(screen.getByText("Select multiple options")).toBeInTheDocument();
53+
});
54+
55+
it("displays error message when error is present", () => {
56+
const fieldWithError = {
57+
...baseField,
58+
error: "At least one option is required",
59+
};
60+
render(<MultiSelect field={fieldWithError} onChange={mockOnChange} />);
61+
62+
expect(
63+
screen.getByText("At least one option is required")
64+
).toBeInTheDocument();
65+
});
66+
67+
it("renders hidden inputs for each selected value for form submission", () => {
68+
const fieldWithValues = {
69+
...baseField,
70+
value: ["option_1", "option_2"],
71+
};
72+
const { container } = render(
73+
<MultiSelect field={fieldWithValues} onChange={mockOnChange} />
74+
);
75+
76+
const hiddenInputs = container.querySelectorAll(
77+
'input[type="hidden"][name="test_multiselect[]"]'
78+
);
79+
expect(hiddenInputs).toHaveLength(2);
80+
expect(hiddenInputs[0]).toHaveValue("option_1");
81+
expect(hiddenInputs[1]).toHaveValue("option_2");
82+
});
83+
84+
it("renders with no hidden inputs when no values selected", () => {
85+
const { container } = render(
86+
<MultiSelect field={baseField} onChange={mockOnChange} />
87+
);
88+
89+
const hiddenInputs = container.querySelectorAll(
90+
'input[type="hidden"][name="test_multiselect[]"]'
91+
);
92+
expect(hiddenInputs).toHaveLength(0);
93+
});
94+
95+
it("handles nested options correctly", () => {
96+
const fieldWithNestedOptions: TicketFieldObject = {
97+
...baseField,
98+
options: [
99+
{ name: "Category::SubCategory::Item", value: "cat_subcat_item" },
100+
{ name: "Another Option", value: "another" },
101+
],
102+
};
103+
104+
const { container } = render(
105+
<MultiSelect field={fieldWithNestedOptions} onChange={mockOnChange} />
106+
);
107+
108+
// Component should render without errors
109+
expect(container).toBeInTheDocument();
110+
});
111+
112+
it("renders with deeply nested selected options", () => {
113+
const fieldWithNestedOptions: TicketFieldObject = {
114+
...baseField,
115+
value: ["cameras_dslr_consumer", "cameras_dslr_pro"],
116+
options: [
117+
{
118+
name: "Cameras::Digital SLR::Consumer",
119+
value: "cameras_dslr_consumer",
120+
},
121+
{
122+
name: "Cameras::Digital SLR::Professional",
123+
value: "cameras_dslr_pro",
124+
},
125+
{ name: "Lenses", value: "lenses" },
126+
],
127+
};
128+
129+
const { container } = render(
130+
<MultiSelect field={fieldWithNestedOptions} onChange={mockOnChange} />
131+
);
132+
133+
const hiddenInputs = container.querySelectorAll(
134+
'input[type="hidden"][name="test_multiselect[]"]'
135+
);
136+
expect(hiddenInputs).toHaveLength(2);
137+
expect(hiddenInputs[0]).toHaveValue("cameras_dslr_consumer");
138+
expect(hiddenInputs[1]).toHaveValue("cameras_dslr_pro");
139+
});
140+
141+
it("initializes with correct values from props", () => {
142+
const fieldWithValues = {
143+
...baseField,
144+
value: ["option_1", "option_4"],
145+
};
146+
const { container } = render(
147+
<MultiSelect field={fieldWithValues} onChange={mockOnChange} />
148+
);
149+
150+
const hiddenInputs = container.querySelectorAll(
151+
'input[type="hidden"][name="test_multiselect[]"]'
152+
);
153+
expect(hiddenInputs).toHaveLength(2);
154+
expect(hiddenInputs[0]).toHaveValue("option_1");
155+
expect(hiddenInputs[1]).toHaveValue("option_4");
156+
});
157+
158+
it("handles empty array value", () => {
159+
const fieldWithEmptyArray = {
160+
...baseField,
161+
value: [],
162+
};
163+
const { container } = render(
164+
<MultiSelect field={fieldWithEmptyArray} onChange={mockOnChange} />
165+
);
166+
167+
const hiddenInputs = container.querySelectorAll(
168+
'input[type="hidden"][name="test_multiselect[]"]'
169+
);
170+
expect(hiddenInputs).toHaveLength(0);
171+
});
172+
173+
it("renders with single selected value", () => {
174+
const fieldWithSingleValue = {
175+
...baseField,
176+
value: ["option_1"],
177+
};
178+
const { container } = render(
179+
<MultiSelect field={fieldWithSingleValue} onChange={mockOnChange} />
180+
);
181+
182+
const hiddenInputs = container.querySelectorAll(
183+
'input[type="hidden"][name="test_multiselect[]"]'
184+
);
185+
expect(hiddenInputs).toHaveLength(1);
186+
expect(hiddenInputs[0]).toHaveValue("option_1");
187+
});
188+
});

0 commit comments

Comments
 (0)