Skip to content

Commit 64a620d

Browse files
fix(secrets): display plain text labels in scope dropdown trigger (#2296)
* fix(license): use useApiStoreHook to fix react-doctor errors Replace useStore(useApiStore, ...) with useApiStoreHook(...) in license notification components. Passing a hook as a value argument to another hook violates React Compiler rules. useApiStoreHook is an existing wrapper that encapsulates the same call pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(secrets): display plain text labels in scope dropdown trigger The MultiSelect trigger was rendering JSX labels with icons, causing overflow and readability issues. Split SCOPE_OPTIONS into string labels for the trigger and JSX children with icons for the dropdown items. Fixes UX-964 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(secrets): apply linter auto-fixes to scope dropdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(secrets): add integration test for scope dropdown labels Verify that MultiSelect trigger displays plain text labels like "AI Gateway" instead of raw enum numbers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(multi-select): remove horizontal scrollbar from trigger Replace overflow-x-scroll with overflow-x-hidden on MultiSelectValue to prevent horizontal scrollbar in the trigger area. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(multi-select): pre-populate item cache for pre-selected values The MultiSelect trigger displayed raw numeric enum values instead of labels when the form had pre-selected values (e.g. secrets create page). The itemCache is a non-reactive ref that only gets populated after MultiSelectItem mounts via useEffect — too late for the first render. Add an optional `items` prop to MultiSelect that seeds the cache at initialization, ensuring labels are available from the first render. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 27d5e58 commit 64a620d

File tree

5 files changed

+183
-36
lines changed

5 files changed

+183
-36
lines changed

frontend/src/components/pages/secrets-store/create/secret-create-page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export const SecretCreatePage = () => {
144144
<Field data-invalid={fieldState.invalid}>
145145
<FieldLabel required>Scopes</FieldLabel>
146146
<MultiSelect
147+
items={SCOPE_OPTIONS}
147148
onValueChange={(values) => field.onChange(values.map(Number))}
148149
value={field.value.map(String)}
149150
>
@@ -153,8 +154,11 @@ export const SecretCreatePage = () => {
153154
<MultiSelectContent>
154155
<MultiSelectList>
155156
{SCOPE_OPTIONS.map((option) => (
156-
<MultiSelectItem key={option.value} {...option}>
157-
{option.label}
157+
<MultiSelectItem key={option.value} label={option.label} value={option.value}>
158+
<span className="flex items-center gap-2">
159+
<option.icon className="size-4" />
160+
{option.label}
161+
</span>
158162
</MultiSelectItem>
159163
))}
160164
</MultiSelectList>

frontend/src/components/pages/secrets-store/edit/secret-edit-page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export const SecretEditPage = () => {
191191
<Field data-invalid={fieldState.invalid}>
192192
<FieldLabel required>Scopes</FieldLabel>
193193
<MultiSelect
194+
items={SCOPE_OPTIONS}
194195
onValueChange={(values) => field.onChange(values.map(Number))}
195196
value={field.value.map(String)}
196197
>
@@ -200,8 +201,11 @@ export const SecretEditPage = () => {
200201
<MultiSelectContent>
201202
<MultiSelectList>
202203
{SCOPE_OPTIONS.map((option) => (
203-
<MultiSelectItem key={option.value} {...option}>
204-
{option.label}
204+
<MultiSelectItem key={option.value} label={option.label} value={option.value}>
205+
<span className="flex items-center gap-2">
206+
<option.icon className="size-4" />
207+
{option.label}
208+
</span>
205209
</MultiSelectItem>
206210
))}
207211
</MultiSelectList>
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Copyright 2025 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with the Business Source License,
8+
* use of this software will be governed by the Apache License, Version 2.0
9+
*/
10+
11+
import { render, screen } from '@testing-library/react';
12+
import userEvent from '@testing-library/user-event';
13+
import {
14+
MultiSelect,
15+
MultiSelectContent,
16+
MultiSelectEmpty,
17+
MultiSelectItem,
18+
MultiSelectList,
19+
MultiSelectTrigger,
20+
MultiSelectValue,
21+
} from 'components/redpanda-ui/components/multi-select';
22+
import { useState } from 'react';
23+
import { beforeAll, describe, expect, it } from 'vitest';
24+
25+
import { SCOPE_OPTIONS } from './secret-form-shared';
26+
27+
// cmdk calls scrollIntoView which is not available in jsdom
28+
beforeAll(() => {
29+
Element.prototype.scrollIntoView = () => {};
30+
});
31+
32+
/**
33+
* Helper component that mirrors the scope MultiSelect usage in the
34+
* secret create / edit pages, but without any form or routing wiring.
35+
*/
36+
function ScopeMultiSelect({ defaultValue = [] }: { defaultValue?: string[] }) {
37+
const [value, setValue] = useState<string[]>(defaultValue);
38+
39+
return (
40+
<MultiSelect onValueChange={setValue} value={value}>
41+
<MultiSelectTrigger data-testid="scope-trigger">
42+
<MultiSelectValue placeholder="Select scopes" />
43+
</MultiSelectTrigger>
44+
<MultiSelectContent>
45+
<MultiSelectList>
46+
{SCOPE_OPTIONS.map((option) => (
47+
<MultiSelectItem key={option.value} label={option.label} value={option.value}>
48+
<span className="flex items-center gap-2">
49+
<option.icon className="size-4" />
50+
{option.label}
51+
</span>
52+
</MultiSelectItem>
53+
))}
54+
</MultiSelectList>
55+
<MultiSelectEmpty>No items found</MultiSelectEmpty>
56+
</MultiSelectContent>
57+
</MultiSelect>
58+
);
59+
}
60+
61+
describe('SCOPE_OPTIONS labels', () => {
62+
it('all labels are plain strings, not JSX or raw enum numbers', () => {
63+
for (const option of SCOPE_OPTIONS) {
64+
expect(typeof option.label).toBe('string');
65+
// The label should not be a stringified number (raw enum value)
66+
expect(Number.isNaN(Number(option.label))).toBe(true);
67+
}
68+
});
69+
70+
it('each option has a string label and a renderable icon component', () => {
71+
const expectedLabels = ['AI Gateway', 'MCP Server', 'AI Agent', 'Redpanda Connect', 'Redpanda Cluster'];
72+
73+
expect(SCOPE_OPTIONS.map((o) => o.label)).toEqual(expectedLabels);
74+
75+
for (const option of SCOPE_OPTIONS) {
76+
// Icons can be functions (lucide) or objects (forwardRef components)
77+
expect(['function', 'object']).toContain(typeof option.icon);
78+
}
79+
});
80+
81+
it('each option value is a numeric string matching a Scope enum value', () => {
82+
for (const option of SCOPE_OPTIONS) {
83+
expect(Number.isNaN(Number(option.value))).toBe(false);
84+
expect(Number(option.value)).toBeGreaterThan(0);
85+
}
86+
});
87+
});
88+
89+
describe('Scope MultiSelect trigger', () => {
90+
it('shows placeholder when no scopes are selected', () => {
91+
render(<ScopeMultiSelect />);
92+
93+
expect(screen.getByText('Select scopes')).toBeInTheDocument();
94+
});
95+
96+
it('displays plain text label after selecting a scope from the dropdown', async () => {
97+
const user = userEvent.setup();
98+
99+
render(<ScopeMultiSelect />);
100+
101+
const trigger = screen.getByTestId('scope-trigger');
102+
103+
// Open the dropdown by clicking the trigger
104+
await user.click(trigger);
105+
106+
// Click the first option ("AI Gateway")
107+
const firstOption = SCOPE_OPTIONS[0];
108+
const optionEl = await screen.findByText(firstOption.label);
109+
await user.click(optionEl);
110+
111+
// The trigger should now show the human-readable label text
112+
expect(trigger).toHaveTextContent(firstOption.label);
113+
114+
// It should NOT show the raw enum value as standalone text
115+
const triggerText = trigger.textContent ?? '';
116+
const rawNumberPattern = new RegExp(`(^|\\s)${firstOption.value}(\\s|$)`);
117+
if (!firstOption.label.includes(firstOption.value)) {
118+
expect(triggerText).not.toMatch(rawNumberPattern);
119+
}
120+
});
121+
122+
it('displays plain text labels for multiple selected scopes', async () => {
123+
const user = userEvent.setup();
124+
125+
render(<ScopeMultiSelect />);
126+
127+
const trigger = screen.getByTestId('scope-trigger');
128+
129+
// Open dropdown and select two options
130+
await user.click(trigger);
131+
132+
const first = SCOPE_OPTIONS[0];
133+
const second = SCOPE_OPTIONS[1];
134+
135+
await user.click(await screen.findByText(first.label));
136+
await user.click(await screen.findByText(second.label));
137+
138+
// Close the dropdown
139+
await user.keyboard('{Escape}');
140+
141+
// Both labels should appear in the trigger
142+
expect(trigger).toHaveTextContent(first.label);
143+
expect(trigger).toHaveTextContent(second.label);
144+
145+
// Raw numeric enum values should not appear as standalone text
146+
const triggerText = trigger.textContent ?? '';
147+
for (const option of [first, second]) {
148+
const rawNumberPattern = new RegExp(`(^|\\s)${option.value}(\\s|$)`);
149+
if (!option.label.includes(option.value)) {
150+
expect(triggerText).not.toMatch(rawNumberPattern);
151+
}
152+
}
153+
});
154+
});

frontend/src/components/pages/secrets-store/secret-form-shared.tsx

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,48 +18,28 @@ export const SECRET_ID_REGEX = /^[a-zA-Z0-9/_-]+$/;
1818
export const SCOPE_OPTIONS = [
1919
{
2020
value: String(Scope.AI_GATEWAY),
21-
label: (
22-
<span className="flex items-center gap-2">
23-
<Waypoints className="size-4" />
24-
AI Gateway
25-
</span>
26-
),
21+
label: 'AI Gateway',
22+
icon: Waypoints,
2723
},
2824
{
2925
value: String(Scope.MCP_SERVER),
30-
label: (
31-
<span className="flex items-center gap-2">
32-
<MCPIcon className="size-4" />
33-
MCP Server
34-
</span>
35-
),
26+
label: 'MCP Server',
27+
icon: MCPIcon,
3628
},
3729
{
3830
value: String(Scope.AI_AGENT),
39-
label: (
40-
<span className="flex items-center gap-2">
41-
<CircleUser className="size-4" />
42-
AI Agent
43-
</span>
44-
),
31+
label: 'AI Agent',
32+
icon: CircleUser,
4533
},
4634
{
4735
value: String(Scope.REDPANDA_CONNECT),
48-
label: (
49-
<span className="flex items-center gap-2">
50-
<Link className="size-4" />
51-
Redpanda Connect
52-
</span>
53-
),
36+
label: 'Redpanda Connect',
37+
icon: Link,
5438
},
5539
{
5640
value: String(Scope.REDPANDA_CLUSTER),
57-
label: (
58-
<span className="flex items-center gap-2">
59-
<Server className="size-4" />
60-
Redpanda Cluster
61-
</span>
62-
),
41+
label: 'Redpanda Cluster',
42+
icon: Server,
6343
},
6444
];
6545

frontend/src/components/redpanda-ui/components/multi-select.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ type MultiSelectProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.R
6969
filter?: boolean | ((keyword: string, current: string) => boolean);
7070
disabled?: boolean;
7171
maxCount?: number;
72+
/** Pre-populate the item cache so labels are available before MultiSelectItem mounts. */
73+
items?: MultiSelectOptionItem[];
7274
};
7375

7476
const MultiSelect: React.FC<MultiSelectProps> = ({
@@ -84,9 +86,12 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
8486
filter,
8587
disabled,
8688
maxCount,
89+
items,
8790
...popoverProps
8891
}) => {
89-
const itemCache = React.useRef(new Map<string, MultiSelectOptionItem>()).current;
92+
const itemCache = React.useRef(new Map<string, MultiSelectOptionItem>(
93+
items?.map((item) => [item.value, item]) ?? [],
94+
)).current;
9095

9196
const handleValueChange = React.useCallback(
9297
(state: string[]) => {
@@ -236,7 +241,7 @@ const MultiSelectValue = React.forwardRef<React.ComponentRef<'div'>, MultiSelect
236241
return (
237242
<TooltipProvider delayDuration={300}>
238243
<div
239-
className={cn('flex flex-1 flex-nowrap items-center gap-0.25 overflow-x-scroll', className)}
244+
className={cn('flex flex-1 flex-nowrap items-center gap-0.25 overflow-x-hidden', className)}
240245
{...props}
241246
ref={forwardRef}
242247
>

0 commit comments

Comments
 (0)