Skip to content

Commit d6353fe

Browse files
authored
Merge pull request #186 from KubrickCode/develop/shlee/185
feat(view): add icon picker component for visual icon selection
2 parents 5d39b1e + f465386 commit d6353fe

File tree

8 files changed

+1070
-11
lines changed

8 files changed

+1070
-11
lines changed

src/view/e2e/icon-picker.spec.ts

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
import {
3+
clearAllCommands,
4+
fillCommandForm,
5+
openAddCommandDialog,
6+
saveCommandDialog,
7+
} from "./helpers/test-helpers";
8+
9+
const TEST_COMMAND = {
10+
name: "Icon Picker Test",
11+
command: "echo 'icon picker test'",
12+
};
13+
14+
const openIconPicker = async (page: Page) => {
15+
await page.getByRole("button", { name: /open icon picker/i }).click();
16+
};
17+
18+
const getIconPickerPopover = (page: Page) => {
19+
return page.getByTestId("icon-picker-popover");
20+
};
21+
22+
const getIconSearchInput = (page: Page) => {
23+
return page.getByPlaceholder(/search icons/i);
24+
};
25+
26+
const getIconPickerButton = (page: Page) => {
27+
return page.getByRole("button", { name: /open icon picker/i });
28+
};
29+
30+
const getDisplayTextInput = (page: Page) => {
31+
return page.getByPlaceholder(/terminal.*build.*deploy/i);
32+
};
33+
34+
const getIconGridButtons = (page: Page) => {
35+
return page.getByTestId("icon-grid-button");
36+
};
37+
38+
const getIconByName = (page: Page, iconName: string) => {
39+
return page.locator(`button[title='${iconName}']`);
40+
};
41+
42+
const getIconsContaining = (page: Page, text: string) => {
43+
return page.locator(`button[title*='${text}']`);
44+
};
45+
46+
test.describe("Icon Picker Component", () => {
47+
test.beforeEach(async ({ page }) => {
48+
await page.goto("/");
49+
await clearAllCommands(page);
50+
});
51+
52+
test("should open icon picker popover when clicking the icon button", async ({ page }) => {
53+
// Given: Open add command dialog
54+
await openAddCommandDialog(page);
55+
56+
// When: Click the icon picker button
57+
await openIconPicker(page);
58+
59+
// Then: Icon picker popover should be visible
60+
const popover = getIconPickerPopover(page);
61+
await expect(popover).toBeVisible();
62+
});
63+
64+
test("should close icon picker popover when clicking outside", async ({ page }) => {
65+
// Given: Open add command dialog and icon picker
66+
await openAddCommandDialog(page);
67+
await openIconPicker(page);
68+
await expect(getIconPickerPopover(page)).toBeVisible();
69+
70+
// When: Click outside the popover
71+
await page.getByLabel(/command name/i).click();
72+
73+
// Then: Popover should be hidden
74+
await expect(getIconPickerPopover(page)).toBeHidden();
75+
});
76+
77+
test("should display icons in a grid", async ({ page }) => {
78+
// Given: Open add command dialog and icon picker
79+
await openAddCommandDialog(page);
80+
await openIconPicker(page);
81+
82+
// Then: Should have icon buttons visible
83+
const iconButtons = getIconGridButtons(page);
84+
await expect(iconButtons.first()).toBeVisible();
85+
86+
// And: Should have multiple icons (at least 50 initially shown)
87+
const count = await iconButtons.count();
88+
expect(count).toBeGreaterThan(50);
89+
});
90+
91+
test("should filter icons when searching", async ({ page }) => {
92+
// Given: Open add command dialog and icon picker
93+
await openAddCommandDialog(page);
94+
await openIconPicker(page);
95+
const initialIconCount = await getIconGridButtons(page).count();
96+
97+
// When: Type search query
98+
await getIconSearchInput(page).fill("terminal");
99+
100+
// Then: Should show filtered icons containing "terminal"
101+
const iconButtons = getIconsContaining(page, "terminal");
102+
await expect(iconButtons.first()).toBeVisible();
103+
104+
// And: Count should be reduced compared to initial
105+
const count = await iconButtons.count();
106+
expect(count).toBeGreaterThan(0);
107+
expect(count).toBeLessThan(initialIconCount);
108+
});
109+
110+
test("should show 'No icons found' when search has no results", async ({ page }) => {
111+
// Given: Open add command dialog and icon picker
112+
await openAddCommandDialog(page);
113+
await openIconPicker(page);
114+
115+
// When: Type search query with no matches
116+
await getIconSearchInput(page).fill("xyznonexistent");
117+
118+
// Then: Should show no icons found message
119+
await expect(page.getByText(/no icons found/i)).toBeVisible();
120+
});
121+
122+
test("should select icon and show in picker button", async ({ page }) => {
123+
// Given: Open add command dialog and icon picker
124+
await openAddCommandDialog(page);
125+
await openIconPicker(page);
126+
127+
// When: Search for terminal and click the terminal icon
128+
await getIconSearchInput(page).fill("terminal");
129+
await getIconByName(page, "terminal").click();
130+
131+
// Then: Popover should close
132+
await expect(getIconPickerPopover(page)).toBeHidden();
133+
134+
// And: Icon picker button should show the selected icon
135+
const iconButton = getIconPickerButton(page);
136+
await expect(iconButton.locator(".codicon-terminal")).toBeVisible();
137+
});
138+
139+
test("should replace existing icon when selecting new icon", async ({ page }) => {
140+
// Given: Open add command dialog and select an icon first
141+
await openAddCommandDialog(page);
142+
await openIconPicker(page);
143+
await getIconSearchInput(page).fill("folder");
144+
await getIconByName(page, "folder").click();
145+
146+
// Add display text
147+
await getDisplayTextInput(page).fill("My Command");
148+
149+
// When: Open icon picker and select a different icon
150+
await openIconPicker(page);
151+
await getIconSearchInput(page).fill("terminal");
152+
await getIconByName(page, "terminal").click();
153+
154+
// Then: Icon picker button should show the new icon
155+
const iconButton = getIconPickerButton(page);
156+
await expect(iconButton.locator(".codicon-terminal")).toBeVisible();
157+
158+
// And: Display text should remain unchanged
159+
await expect(getDisplayTextInput(page)).toHaveValue("My Command");
160+
});
161+
162+
test("should show selected icon in the picker button", async ({ page }) => {
163+
// Given: Open add command dialog
164+
await openAddCommandDialog(page);
165+
166+
// When: Select an icon via picker
167+
await openIconPicker(page);
168+
await getIconSearchInput(page).fill("terminal");
169+
await getIconByName(page, "terminal").click();
170+
171+
// Then: Icon picker button should show the terminal icon
172+
const iconButton = getIconPickerButton(page);
173+
await expect(iconButton.locator(".codicon-terminal")).toBeVisible();
174+
});
175+
176+
test("should show 'Icon' label when no icon is selected", async ({ page }) => {
177+
// Given: Open add command dialog
178+
await openAddCommandDialog(page);
179+
180+
// Then: Icon picker button should show "Icon" label (making purpose clear)
181+
const iconButton = getIconPickerButton(page);
182+
await expect(iconButton).toContainText(/icon/i);
183+
});
184+
185+
test("should save command with selected icon", async ({ page }) => {
186+
// Given: Open add command dialog
187+
await openAddCommandDialog(page);
188+
189+
// When: Select an icon and fill form
190+
await openIconPicker(page);
191+
await getIconSearchInput(page).fill("rocket");
192+
await getIconByName(page, "rocket").click();
193+
194+
// Fill in display text (separate from icon)
195+
await getDisplayTextInput(page).fill("Launch");
196+
197+
await fillCommandForm(page, {
198+
command: TEST_COMMAND.command,
199+
});
200+
await saveCommandDialog(page);
201+
202+
// Then: Command should be saved with icon
203+
const commandCard = page.locator('[data-testid="command-card"]', {
204+
hasText: "Launch",
205+
});
206+
await expect(commandCard).toBeVisible();
207+
208+
// And: Icon should be visible in the card
209+
await expect(commandCard.locator(".codicon-rocket")).toBeVisible();
210+
});
211+
212+
test("should preserve icon when editing command", async ({ page }) => {
213+
// Given: Create a command with icon
214+
await openAddCommandDialog(page);
215+
await openIconPicker(page);
216+
await getIconSearchInput(page).fill("debug");
217+
await getIconByName(page, "debug").click();
218+
219+
// Fill display text (separate input)
220+
await getDisplayTextInput(page).fill("Debug Test");
221+
await fillCommandForm(page, {
222+
command: "echo 'debug'",
223+
});
224+
await saveCommandDialog(page);
225+
226+
// When: Open edit dialog
227+
const commandCard = page.locator('[data-testid="command-card"]', {
228+
hasText: "Debug Test",
229+
});
230+
await commandCard.getByRole("button", { name: /edit/i }).click();
231+
232+
// Then: Display text input should have the text
233+
await expect(getDisplayTextInput(page)).toHaveValue("Debug Test");
234+
235+
// And: Icon picker button should show the debug icon
236+
await expect(getIconPickerButton(page).locator(".codicon-debug")).toBeVisible();
237+
});
238+
239+
test("should close popover with Escape key", async ({ page }) => {
240+
// Given: Open add command dialog and icon picker
241+
await openAddCommandDialog(page);
242+
await openIconPicker(page);
243+
await expect(getIconPickerPopover(page)).toBeVisible();
244+
245+
// When: Press Escape key
246+
await page.keyboard.press("Escape");
247+
248+
// Then: Popover should be hidden
249+
await expect(getIconPickerPopover(page)).toBeHidden();
250+
});
251+
252+
test("should show 'Show all' button when more than 100 icons available", async ({ page }) => {
253+
// Given: Open add command dialog and icon picker
254+
await openAddCommandDialog(page);
255+
await openIconPicker(page);
256+
257+
// Then: Should show 'Show all' button
258+
const showAllButton = page.getByRole("button", { name: /show all.*icons/i });
259+
await expect(showAllButton).toBeVisible();
260+
});
261+
262+
test("should show all icons when clicking 'Show all' button", async ({ page }) => {
263+
// Given: Open add command dialog and icon picker
264+
await openAddCommandDialog(page);
265+
await openIconPicker(page);
266+
267+
// Get initial icon count
268+
const initialCount = await getIconGridButtons(page).count();
269+
270+
// When: Click 'Show all' button
271+
await page.getByRole("button", { name: /show all.*icons/i }).click();
272+
273+
// Then: Should show more icons
274+
const finalCount = await getIconGridButtons(page).count();
275+
expect(finalCount).toBeGreaterThan(initialCount);
276+
277+
// And: 'Show all' button should be hidden
278+
await expect(page.getByRole("button", { name: /show all.*icons/i })).toBeHidden();
279+
});
280+
281+
test("should highlight currently selected icon in the grid", async ({ page }) => {
282+
// Given: Open add command dialog and select an icon
283+
await openAddCommandDialog(page);
284+
await openIconPicker(page);
285+
await getIconSearchInput(page).fill("terminal");
286+
await getIconByName(page, "terminal").click();
287+
288+
// When: Open icon picker again
289+
await openIconPicker(page);
290+
await getIconSearchInput(page).fill("terminal");
291+
292+
// Then: Terminal icon button should have selected styling (bg-accent class)
293+
const terminalButton = getIconByName(page, "terminal");
294+
await expect(terminalButton).toHaveClass(/bg-accent/);
295+
});
296+
297+
test("should allow creating command with only display text (no icon)", async ({ page }) => {
298+
// Given: Open add command dialog
299+
await openAddCommandDialog(page);
300+
301+
// When: Only fill display text without selecting an icon
302+
await getDisplayTextInput(page).fill("No Icon Button");
303+
await fillCommandForm(page, {
304+
command: "echo 'test'",
305+
});
306+
await saveCommandDialog(page);
307+
308+
// Then: Command should be saved without icon
309+
const commandCard = page.locator('[data-testid="command-card"]', {
310+
hasText: "No Icon Button",
311+
});
312+
await expect(commandCard).toBeVisible();
313+
314+
// And: No codicon should be visible in the card
315+
await expect(commandCard.locator(".codicon")).toBeHidden();
316+
});
317+
});

src/view/src/components/command-form.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import {
2020
} from "~/core";
2121

2222
import { ColorInput } from "./color-input";
23+
import { GroupCommandEditor } from "./group-command-editor";
24+
import { GroupToSingleWarningDialog } from "./group-to-single-warning-dialog";
25+
import { IconPicker } from "./icon-picker";
2326
import { createCommandFormSchema } from "../schemas/command-form-schema";
2427
import {
2528
type ButtonConfig,
@@ -28,8 +31,7 @@ import {
2831
toCommandButton,
2932
toGroupButton,
3033
} from "../types";
31-
import { GroupCommandEditor } from "./group-command-editor";
32-
import { GroupToSingleWarningDialog } from "./group-to-single-warning-dialog";
34+
import { parseVSCodeIconName } from "../utils/parse-vscode-icon-name";
3335

3436
type CommandFormProps = {
3537
command?: (ButtonConfig & { index?: number }) | null;
@@ -124,16 +126,48 @@ export const CommandForm = ({ command, commands, formId, onSave }: CommandFormPr
124126
setIsGroupMode(value === "group");
125127
};
126128

129+
// Parse initial name into icon and display text
130+
const initialParsed = useMemo(() => {
131+
const name = command?.name || "";
132+
return parseVSCodeIconName(name);
133+
}, [command?.name]);
134+
135+
const [selectedIcon, setSelectedIcon] = useState<string | undefined>(initialParsed.iconName);
136+
const [displayText, setDisplayText] = useState(initialParsed.displayText);
137+
138+
const getCombinedName = (icon?: string, text?: string): string => {
139+
if (icon) {
140+
return `$(${icon})${text ? ` ${text}` : ""}`;
141+
}
142+
return text || "";
143+
};
144+
145+
const handleIconChange = (icon: string | undefined) => {
146+
setSelectedIcon(icon);
147+
setValue("name", getCombinedName(icon, displayText));
148+
};
149+
150+
const handleDisplayTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
151+
const newText = e.target.value;
152+
setDisplayText(newText);
153+
setValue("name", getCombinedName(selectedIcon, newText));
154+
};
155+
127156
return (
128157
<form className="space-y-6" id={formId} onSubmit={onSubmit}>
129158
<div className="space-y-6">
130159
<div className="space-y-2">
131-
<FormLabel htmlFor="name">{t("commandForm.commandName")}</FormLabel>
132-
<Input
133-
id="name"
134-
placeholder={t("commandForm.commandNamePlaceholder")}
135-
{...register("name")}
136-
/>
160+
<FormLabel htmlFor="displayText">{t("commandForm.commandName")}</FormLabel>
161+
<div className="flex h-9 w-full rounded-md bg-background-subtle text-sm input-premium">
162+
<IconPicker onChange={handleIconChange} value={selectedIcon} />
163+
<input
164+
className="flex-1 bg-transparent px-3 py-1 outline-none placeholder:text-muted-foreground"
165+
id="displayText"
166+
onChange={handleDisplayTextChange}
167+
placeholder={t("commandForm.displayTextPlaceholder")}
168+
value={displayText}
169+
/>
170+
</div>
137171
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
138172
</div>
139173

0 commit comments

Comments
 (0)