Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7a0510a

Browse files
committed
refactor: show icons for multi-select parameter options
1 parent fdf458e commit 7a0510a

File tree

4 files changed

+325
-124
lines changed

4 files changed

+325
-124
lines changed

docs/about/contributing/frontend.md

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -259,18 +259,16 @@ We use [Formik](https://formik.org/docs) for forms along with
259259

260260
## Testing
261261

262-
We use three types of testing in our app: **End-to-end (E2E)**, **Integration**
262+
We use three types of testing in our app: **End-to-end (E2E)**, **Integration/Unit**
263263
and **Visual Testing**.
264264

265-
### End-to-End (E2E)
265+
### End-to-End (E2E) – Playwright
266266

267267
These are useful for testing complete flows like "Create a user", "Import
268-
template", etc. We use [Playwright](https://playwright.dev/). If you only need
269-
to test if the page is being rendered correctly, you should consider using the
270-
**Visual Testing** approach.
268+
template", etc. We use [Playwright](https://playwright.dev/). These tests run against a full Coder instance, backed by a database, and allows you to make sure that features work properly all the way through the stack. "End to end", so to speak.
271269

272-
For scenarios where you need to be authenticated, you can use
273-
`test.use({ storageState: getStatePath("authState") })`.
270+
For scenarios where you need to be authenticated as a certain user, you can use
271+
`login` helper. Passing it some user credentials will log out of any other user account, and will attempt to login using those credentials.
274272

275273
For ease of debugging, it's possible to run a Playwright test in headful mode
276274
running a Playwright server on your local machine, and executing the test inside
@@ -289,22 +287,14 @@ local machine and forward the necessary ports to your workspace. At the end of
289287
the script, you will land _inside_ your workspace with environment variables set
290288
so you can simply execute the test (`pnpm run playwright:test`).
291289

292-
### Integration
290+
### Integration/Unit – Jest
293291

294-
Test user interactions like "Click in a button shows a dialog", "Submit the form
295-
sends the correct data", etc. For this, we use [Jest](https://jestjs.io/) and
296-
[react-testing-library](https://testing-library.com/docs/react-testing-library/intro/).
297-
If the test involves routing checks like redirects or maybe checking the info on
298-
another page, you should probably consider using the **E2E** approach.
292+
We use Jest mostly for testing code that does _not_ pertain to React. Functions and classes that contain notable app logic, and which are well abstracted from React should have accompanying tests. If the logic is tightly coupled to a React component, a Storybook test or an E2E test may be a better option depending on the scenario.
299293

300-
### Visual testing
294+
### Visual Testing – Storybook
301295

302-
We use visual tests to test components without user interaction like testing if
303-
a page/component is rendered correctly depending on some parameters, if a button
304-
is showing a spinner, if `loading` props are passed correctly, etc. This should
305-
always be your first option since it is way easier to maintain. For this, we use
306-
[Storybook](https://storybook.js.org/) and
307-
[Chromatic](https://www.chromatic.com/).
296+
We use Storybook for testing all of our React code. For static components, you simply add a story that renders the components with the props that you would like to test, and Storybook will record snapshots of it to ensure that it isn't changed unintentionally. If you would like to test an interaction with the component, then you can add an interaction test by specifying a `play` function for the story. For stories with an interaction test, a snapshot will be recorded of the end state of the component. We use
297+
[Chromatic](https://www.chromatic.com/) to manage and compare snapshots in CI.
308298

309299
To learn more about testing components that fetch API data, refer to the
310300
[**Where to fetch data**](#where-to-fetch-data) section.

site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,184 @@ export const ClearAllComboboxItems: Story = {
9898
);
9999
},
100100
};
101+
102+
export const WithIcons: Story = {
103+
args: {
104+
placeholder: "Select technology",
105+
emptyIndicator: (
106+
<p className="text-center text-md text-content-primary">
107+
All technologies selected
108+
</p>
109+
),
110+
options: [
111+
{
112+
label: "Docker",
113+
value: "docker",
114+
icon: "/icon/docker.png",
115+
},
116+
{
117+
label: "Kubernetes",
118+
value: "kubernetes",
119+
icon: "/icon/k8s.png",
120+
},
121+
{
122+
label: "VS Code",
123+
value: "vscode",
124+
icon: "/icon/code.svg",
125+
},
126+
{
127+
label: "JetBrains",
128+
value: "jetbrains",
129+
icon: "/icon/intellij.svg",
130+
},
131+
{
132+
label: "Jupyter",
133+
value: "jupyter",
134+
icon: "/icon/jupyter.svg",
135+
},
136+
],
137+
},
138+
play: async ({ canvasElement }) => {
139+
const canvas = within(canvasElement);
140+
141+
// Open the combobox
142+
await userEvent.click(canvas.getByPlaceholderText("Select technology"));
143+
144+
// Verify that Docker option has an icon
145+
const dockerOption = canvas.getByRole("option", { name: /Docker/i });
146+
const dockerIcon = dockerOption.querySelector("img");
147+
await expect(dockerIcon).toBeInTheDocument();
148+
await expect(dockerIcon).toHaveAttribute("src", "/icon/docker.png");
149+
150+
// Select Docker and verify icon appears in badge
151+
await userEvent.click(dockerOption);
152+
153+
// Find the Docker badge
154+
const dockerBadge = canvas
155+
.getByText("Docker")
156+
.closest('[role="button"]')?.parentElement;
157+
const badgeIcon = dockerBadge?.querySelector("img");
158+
await expect(badgeIcon).toBeInTheDocument();
159+
await expect(badgeIcon).toHaveAttribute("src", "/icon/docker.png");
160+
},
161+
};
162+
163+
export const MixedWithAndWithoutIcons: Story = {
164+
args: {
165+
placeholder: "Select resource",
166+
options: [
167+
{
168+
label: "CPU",
169+
value: "cpu",
170+
icon: "/icon/memory.svg",
171+
},
172+
{
173+
label: "Memory",
174+
value: "memory",
175+
icon: "/icon/memory.svg",
176+
},
177+
{
178+
label: "Storage",
179+
value: "storage",
180+
},
181+
{
182+
label: "Network",
183+
value: "network",
184+
},
185+
],
186+
},
187+
play: async ({ canvasElement }) => {
188+
const canvas = within(canvasElement);
189+
190+
// Open the combobox
191+
await userEvent.click(canvas.getByPlaceholderText("Select resource"));
192+
193+
// Verify that CPU option has an icon
194+
const cpuOption = canvas.getByRole("option", { name: /CPU/i });
195+
const cpuIcon = cpuOption.querySelector("img");
196+
await expect(cpuIcon).toBeInTheDocument();
197+
198+
// Verify that Storage option does not have an icon
199+
const storageOption = canvas.getByRole("option", { name: /Storage/i });
200+
const storageIcon = storageOption.querySelector("img");
201+
await expect(storageIcon).not.toBeInTheDocument();
202+
203+
// Select both and verify badges
204+
await userEvent.click(cpuOption);
205+
await userEvent.click(storageOption);
206+
207+
// CPU badge should have icon
208+
const cpuBadge = canvas
209+
.getByText("CPU")
210+
.closest('[role="button"]')?.parentElement;
211+
const cpuBadgeIcon = cpuBadge?.querySelector("img");
212+
await expect(cpuBadgeIcon).toBeInTheDocument();
213+
214+
// Storage badge should not have icon
215+
const storageBadge = canvas
216+
.getByText("Storage")
217+
.closest('[role="button"]')?.parentElement;
218+
const storageBadgeIcon = storageBadge?.querySelector("img");
219+
await expect(storageBadgeIcon).not.toBeInTheDocument();
220+
},
221+
};
222+
223+
export const WithGroupedIcons: Story = {
224+
args: {
225+
placeholder: "Select tools",
226+
groupBy: "category",
227+
options: [
228+
{
229+
label: "Docker",
230+
value: "docker",
231+
icon: "/icon/docker.png",
232+
category: "Containers",
233+
},
234+
{
235+
label: "Kubernetes",
236+
value: "kubernetes",
237+
icon: "/icon/k8s.png",
238+
category: "Containers",
239+
},
240+
{
241+
label: "VS Code",
242+
value: "vscode",
243+
icon: "/icon/code.svg",
244+
category: "IDEs",
245+
},
246+
{
247+
label: "JetBrains",
248+
value: "jetbrains",
249+
icon: "/icon/intellij.svg",
250+
category: "IDEs",
251+
},
252+
{
253+
label: "Zed",
254+
value: "zed",
255+
icon: "/icon/zed.svg",
256+
category: "IDEs",
257+
},
258+
],
259+
},
260+
play: async ({ canvasElement }) => {
261+
const canvas = within(canvasElement);
262+
263+
// Open the combobox
264+
await userEvent.click(canvas.getByPlaceholderText("Select tools"));
265+
266+
// Verify grouped options still have icons
267+
const dockerOption = canvas.getByRole("option", { name: /Docker/i });
268+
const dockerIcon = dockerOption.querySelector("img");
269+
await expect(dockerIcon).toBeInTheDocument();
270+
await expect(dockerIcon).toHaveAttribute("src", "/icon/docker.png");
271+
272+
const vscodeOption = canvas.getByRole("option", { name: /VS Code/i });
273+
const vscodeIcon = vscodeOption.querySelector("img");
274+
await expect(vscodeIcon).toBeInTheDocument();
275+
await expect(vscodeIcon).toHaveAttribute("src", "/icon/code.svg");
276+
277+
// Verify grouping headers are present
278+
await expect(canvas.getByText("Containers")).toBeInTheDocument();
279+
await expect(canvas.getByText("IDEs")).toBeInTheDocument();
280+
},
281+
};

site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* This component is based on multiple-selector
33
* @see {@link https://shadcnui-expansions.typeart.cc/docs/multiple-selector}
44
*/
5+
import { useTheme } from "@emotion/react";
56
import { Command as CommandPrimitive, useCommandState } from "cmdk";
67
import { Badge } from "components/Badge/Badge";
78
import {
@@ -25,11 +26,13 @@ import {
2526
useRef,
2627
useState,
2728
} from "react";
29+
import { getExternalImageStylesFromUrl } from "theme/externalImages";
2830
import { cn } from "utils/cn";
2931

3032
export interface Option {
3133
value: string;
3234
label: string;
35+
icon?: string;
3336
disable?: boolean;
3437
/** fixed option that can't be removed. */
3538
fixed?: boolean;
@@ -204,6 +207,7 @@ export const MultiSelectCombobox = forwardRef<
204207
const [onScrollbar, setOnScrollbar] = useState(false);
205208
const [isLoading, setIsLoading] = useState(false);
206209
const dropdownRef = useRef<HTMLDivElement>(null);
210+
const theme = useTheme();
207211

208212
const [selected, setSelected] = useState<Option[]>(
209213
arrayDefaultOptions ?? [],
@@ -487,7 +491,20 @@ export const MultiSelectCombobox = forwardRef<
487491
data-fixed={option.fixed}
488492
data-disabled={disabled || undefined}
489493
>
490-
{option.label}
494+
<div className="flex items-center gap-1">
495+
{option.icon && (
496+
<img
497+
src={option.icon}
498+
alt=""
499+
className="size-icon-xs"
500+
css={getExternalImageStylesFromUrl(
501+
theme.externalImages,
502+
option.icon,
503+
)}
504+
/>
505+
)}
506+
{option.label}
507+
</div>
491508
<button
492509
type="button"
493510
data-testid="clear-option-button"
@@ -639,7 +656,20 @@ export const MultiSelectCombobox = forwardRef<
639656
"cursor-default text-content-disabled",
640657
)}
641658
>
642-
{option.label}
659+
<div className="flex items-center gap-2">
660+
{option.icon && (
661+
<img
662+
src={option.icon}
663+
alt=""
664+
className="size-icon-sm"
665+
css={getExternalImageStylesFromUrl(
666+
theme.externalImages,
667+
option.icon,
668+
)}
669+
/>
670+
)}
671+
{option.label}
672+
</div>
643673
</CommandItem>
644674
);
645675
})}

0 commit comments

Comments
 (0)