Skip to content

Commit 2d25c91

Browse files
laileni-awsYour Name
andauthored
feat: Add FormItemPillBox Component for Multi-Value Input (#418)
* feat: add FormItemPillBox component for multi-value input - Add new FormItemPillBox component for entering multiple values as pills - Users can type text and press Enter to create pills - Pills can be removed by clicking the × button - Component supports get/set values as comma-separated strings - Includes comprehensive unit tests with 89.85% coverage - Add component to ComponentOverrides interface for customization - Styled with consistent Mynah UI design patterns * fix: resolve linting errors in pill box component - Remove unused imports (generateUID, SingularFormItem) - Use nullish coalescing operator (??) instead of logical OR - Add explicit string/boolean checks for strict boolean expressions - Fix trailing spaces and missing newlines * fix: format SCSS file with prettier * feat: adding pill box chat item form items type * fix: lint errors * minor edits * Updating documentation --------- Co-authored-by: Your Name <[email protected]>
1 parent 0f8e458 commit 2d25c91

File tree

7 files changed

+401
-5
lines changed

7 files changed

+401
-5
lines changed

docs/DATAMODEL.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,7 +1153,7 @@ interface ChatItemButton {
11531153
icon?: MynahIcons;
11541154
}
11551155

1156-
type ChatItemFormItem = TextBasedFormItem | DropdownFormItem | RadioGroupFormItem | CheckboxFormItem | ListFormItem | Stars;
1156+
type ChatItemFormItem = TextBasedFormItem | DropdownFormItem | RadioGroupFormItem | CheckboxFormItem | ListFormItem | Stars | PillboxFormItem;
11571157

11581158
export interface ValidationPattern {
11591159
pattern: string | RegExp;
@@ -1240,6 +1240,11 @@ export interface ListItemEntry {
12401240
value: Record<string, string>;
12411241
}
12421242

1243+
type PillboxFormItem = BaseFormItem & {
1244+
type: 'pillbox';
1245+
value?: string;
1246+
};
1247+
12431248
interface FileNodeAction {
12441249
name: string;
12451250
label?: string;
@@ -2791,7 +2796,7 @@ Let's take a look to the data type of a form item:
27912796
```typescript
27922797
interface ChatItemFormItem {
27932798
id: string; // id is mandatory to understand to get the specific values for each form item when a button is clicked
2794-
type: 'select' | 'textarea' | 'textinput' | 'numericinput' | 'stars' | 'radiogroup' | 'toggle' | 'checkbox' | 'switch' ; // type (see below for each of them)
2799+
type: 'select' | 'textarea' | 'textinput' | 'numericinput' | 'stars' | 'radiogroup' | 'toggle' | 'checkbox' | 'switch' | 'pillbox' ; // type (see below for each of them)
27952800
mandatory?: boolean; // If it is set to true, buttons in the same card with waitMandatoryFormItems set to true will wait them to be filled
27962801
hideMandatoryIcon?: boolean; // If it is set to true, it won't render an asterisk icon next to the form label
27972802
title?: string; // Label of the input
@@ -2978,6 +2983,14 @@ mynahUI.addChatItem(tabId, {
29782983
type: 'stars',
29792984
title: `How do feel about our AI assistant in general?`,
29802985
},
2986+
{
2987+
id: 'skills',
2988+
type: 'pillbox',
2989+
title: 'Programming Languages & Technologies',
2990+
description: 'Add your programming languages and technologies (press Enter to add)',
2991+
placeholder: 'Type a skill and press Enter',
2992+
value: 'JavaScript,TypeScript,React,Node.js',
2993+
},
29812994
{
29822995
id: 'description',
29832996
type: 'textarea',

example/src/samples/sample-data.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,14 @@ _To send the form, mandatory items should be filled._`,
933933
type: 'stars',
934934
title: `How do feel about our AI assistant in general?`,
935935
},
936+
{
937+
id: 'skills',
938+
type: 'pillbox',
939+
title: 'Skills and Technologies',
940+
description: 'Add your programming languages and technologies (press Enter to add)',
941+
placeholder: 'Type a skill and press Enter',
942+
value: 'JavaScript,TypeScript,React',
943+
},
936944
{
937945
id: 'description',
938946
type: 'textarea',
@@ -2342,6 +2350,13 @@ export const sampleMCPDetails = (title: string): DetailedList => {
23422350
description: 'Seconds',
23432351
id: 'timeout',
23442352
},
2353+
{ // Add mandatory field
2354+
id: 'args-pillbox',
2355+
type: 'pillbox',
2356+
title: 'Arguments - pillbox',
2357+
placeholder: 'Type arguments and press Enter',
2358+
value: '-y,@modelcontextprotocol/server-filesystem,/Users/username/Desktop,/path/to/other/allowed/dir',
2359+
},
23452360
{
23462361
id: 'args',
23472362
type: 'list',
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { FormItemPillBox } from '../../../components/form-items/form-item-pill-box';
2+
3+
describe('FormItemPillBox', () => {
4+
let pillBox: FormItemPillBox;
5+
6+
beforeEach(() => {
7+
pillBox = new FormItemPillBox({
8+
id: 'test-pill-box',
9+
label: 'Test Pills',
10+
placeholder: 'Add a pill'
11+
});
12+
document.body.appendChild(pillBox.render);
13+
});
14+
15+
afterEach(() => {
16+
document.body.innerHTML = '';
17+
});
18+
19+
it('should render pill box', () => {
20+
expect(pillBox.render).toBeDefined();
21+
expect(pillBox.render.querySelector('.mynah-form-item-pill-box-wrapper')).toBeTruthy();
22+
});
23+
24+
it('should add pill on enter', () => {
25+
const input = pillBox.render.querySelector('.mynah-form-item-pill-box-input') as HTMLTextAreaElement;
26+
input.value = 'test-pill';
27+
28+
const event = new KeyboardEvent('keydown', { key: 'Enter' });
29+
input.dispatchEvent(event);
30+
31+
expect(pillBox.getValue()).toBe('test-pill');
32+
expect(pillBox.render.querySelector('.mynah-form-item-pill')).toBeTruthy();
33+
});
34+
35+
it('should remove pill on click', () => {
36+
pillBox.setValue('pill1,pill2');
37+
38+
const removeButton = pillBox.render.querySelector('.mynah-form-item-pill-remove') as HTMLElement;
39+
removeButton.click();
40+
41+
expect(pillBox.getValue()).toBe('pill2');
42+
});
43+
44+
it('should set and get values', () => {
45+
pillBox.setValue('tag1,tag2,tag3');
46+
expect(pillBox.getValue()).toBe('tag1,tag2,tag3');
47+
});
48+
49+
it('should disable component', () => {
50+
pillBox.setEnabled(false);
51+
expect(pillBox.render.hasAttribute('disabled')).toBe(true);
52+
});
53+
});

src/components/chat-item/chat-item-form-items.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Card } from '../card/card';
1313
import { CardBody } from '../card/card-body';
1414
import { Checkbox } from '../form-items/checkbox';
1515
import { FormItemList } from '../form-items/form-item-list';
16+
import { FormItemPillBox } from '../form-items/form-item-pill-box';
1617
import { RadioGroup } from '../form-items/radio-group';
1718
import { Select } from '../form-items/select';
1819
import { Stars } from '../form-items/stars';
@@ -32,7 +33,7 @@ export interface ChatItemFormItemsWrapperProps {
3233
}
3334
export class ChatItemFormItemsWrapper {
3435
private readonly props: ChatItemFormItemsWrapperProps;
35-
private readonly options: Record<string, Select | TextArea | TextInput | RadioGroup | Stars | Checkbox | FormItemList> = {};
36+
private readonly options: Record<string, Select | TextArea | TextInput | RadioGroup | Stars | Checkbox | FormItemList | FormItemPillBox> = {};
3637
private readonly validationItems: Record<string, boolean> = {};
3738
private isValid: boolean = false;
3839
private tooltipOverlay: Overlay | null;
@@ -235,6 +236,17 @@ export class ChatItemFormItemsWrapper {
235236
...(this.getHandlers(chatItemOption))
236237
});
237238
break;
239+
case 'pillbox':
240+
chatOption = new FormItemPillBox({
241+
id: chatItemOption.id,
242+
wrapperTestId: testIds.chatItem.chatItemForm.itemInput,
243+
label,
244+
description,
245+
value,
246+
placeholder: chatItemOption.placeholder,
247+
...(this.getHandlers(chatItemOption))
248+
});
249+
break;
238250
case 'stars':
239251
chatOption = new Stars({
240252
wrapperTestId: testIds.chatItem.chatItemForm.itemStarsWrapper,
@@ -313,7 +325,7 @@ export class ChatItemFormItemsWrapper {
313325

314326
private readonly getHandlers = (chatItemOption: ChatItemFormItem): Object => {
315327
if (chatItemOption.mandatory === true ||
316-
([ 'textarea', 'textinput', 'numericinput', 'email' ].includes(chatItemOption.type) && (chatItemOption as TextBasedFormItem).validationPatterns != null)) {
328+
([ 'textarea', 'textinput', 'numericinput', 'email', 'pillbox' ].includes(chatItemOption.type) && (chatItemOption as TextBasedFormItem).validationPatterns != null)) {
317329
// Set initial validation status
318330
this.validationItems[chatItemOption.id] = this.isItemValid(chatItemOption.value as string ?? '', chatItemOption);
319331
return {
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*!
2+
* Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Config } from '../../helper/config';
7+
import { DomBuilder, ExtendedHTMLElement } from '../../helper/dom';
8+
import { cancelEvent } from '../../helper/events';
9+
import { StyleLoader } from '../../helper/style-loader';
10+
11+
export interface FormItemPillBoxProps {
12+
id: string;
13+
value?: string;
14+
classNames?: string[];
15+
attributes?: Record<string, string>;
16+
label?: HTMLElement | ExtendedHTMLElement | string;
17+
description?: ExtendedHTMLElement;
18+
placeholder?: string;
19+
wrapperTestId?: string;
20+
onChange?: (value: string) => void;
21+
disabled?: boolean;
22+
}
23+
24+
export abstract class FormItemPillBoxAbstract {
25+
render: ExtendedHTMLElement;
26+
setValue = (value: string): void => { };
27+
getValue = (): string => '';
28+
setEnabled = (enabled: boolean): void => { };
29+
}
30+
31+
export class FormItemPillBoxInternal extends FormItemPillBoxAbstract {
32+
private readonly props: FormItemPillBoxProps;
33+
private readonly pillsContainer: ExtendedHTMLElement;
34+
private readonly input: ExtendedHTMLElement;
35+
private readonly wrapper: ExtendedHTMLElement;
36+
private pills: string[] = [];
37+
render: ExtendedHTMLElement;
38+
39+
constructor (props: FormItemPillBoxProps) {
40+
StyleLoader.getInstance().load('components/form-items/_form-item-pill-box.scss');
41+
super();
42+
this.props = props;
43+
44+
// Create pills container
45+
this.pillsContainer = DomBuilder.getInstance().build({
46+
type: 'div',
47+
classNames: [ 'mynah-form-item-pill-box-pills-container' ],
48+
});
49+
50+
// Create input field
51+
this.input = DomBuilder.getInstance().build({
52+
type: 'textarea',
53+
classNames: [ 'mynah-form-item-pill-box-input' ],
54+
attributes: {
55+
placeholder: props.placeholder ?? 'Type and press Enter to add a tag',
56+
rows: '1',
57+
},
58+
events: {
59+
keydown: (e: KeyboardEvent) => {
60+
if (e.key === 'Enter' && !e.shiftKey) {
61+
cancelEvent(e);
62+
const value = (this.input as unknown as HTMLTextAreaElement).value.trim();
63+
if (value !== '') {
64+
this.addPill(value);
65+
(this.input as unknown as HTMLTextAreaElement).value = '';
66+
this.notifyChange();
67+
}
68+
}
69+
}
70+
}
71+
});
72+
73+
// Create wrapper
74+
this.wrapper = DomBuilder.getInstance().build({
75+
type: 'div',
76+
classNames: [ 'mynah-form-item-pill-box-wrapper' ],
77+
children: [
78+
this.pillsContainer,
79+
this.input
80+
]
81+
});
82+
83+
// Create main container
84+
this.render = DomBuilder.getInstance().build({
85+
type: 'div',
86+
classNames: [ 'mynah-form-input-wrapper', ...(props.classNames ?? []) ],
87+
attributes: props.attributes,
88+
testId: props.wrapperTestId,
89+
children: [
90+
{
91+
type: 'span',
92+
classNames: [ 'mynah-form-input-label' ],
93+
children: [
94+
...(props.label !== undefined ? [ props.label ] : []),
95+
]
96+
},
97+
...(props.description !== undefined ? [ props.description ] : []),
98+
this.wrapper
99+
]
100+
});
101+
102+
// Initialize with existing value
103+
if (props.value != null) {
104+
this.setValue(props.value);
105+
}
106+
107+
// Set initial disabled state
108+
if (props.disabled === true) {
109+
this.setEnabled(false);
110+
}
111+
}
112+
113+
private addPill (text: string): void {
114+
if (text === '' || this.pills.includes(text)) {
115+
return;
116+
}
117+
118+
this.pills.push(text);
119+
120+
const pill = DomBuilder.getInstance().build({
121+
type: 'div',
122+
classNames: [ 'mynah-form-item-pill' ],
123+
children: [
124+
{
125+
type: 'span',
126+
classNames: [ 'mynah-form-item-pill-text' ],
127+
children: [ text ]
128+
},
129+
{
130+
type: 'span',
131+
classNames: [ 'mynah-form-item-pill-remove' ],
132+
children: [ '×' ],
133+
events: {
134+
click: (e) => {
135+
cancelEvent(e);
136+
pill.remove();
137+
this.pills = this.pills.filter(p => p !== text);
138+
this.notifyChange();
139+
}
140+
}
141+
}
142+
]
143+
});
144+
145+
this.pillsContainer.appendChild(pill);
146+
}
147+
148+
private notifyChange (): void {
149+
if (this.props.onChange != null) {
150+
this.props.onChange(this.getValue());
151+
}
152+
}
153+
154+
setValue = (value: string): void => {
155+
// Clear existing pills
156+
this.pillsContainer.innerHTML = '';
157+
this.pills = [];
158+
159+
// Add new pills
160+
if (value !== '') {
161+
const pillValues = value.split(/[,\n]+/).map(v => v.trim()).filter(v => v);
162+
pillValues.forEach(pill => this.addPill(pill));
163+
}
164+
};
165+
166+
getValue = (): string => {
167+
return this.pills.join(',');
168+
};
169+
170+
setEnabled = (enabled: boolean): void => {
171+
if (enabled) {
172+
this.render.removeAttribute('disabled');
173+
(this.input as unknown as HTMLTextAreaElement).disabled = false;
174+
} else {
175+
this.render.setAttribute('disabled', 'disabled');
176+
(this.input as unknown as HTMLTextAreaElement).disabled = true;
177+
}
178+
};
179+
}
180+
181+
export class FormItemPillBox extends FormItemPillBoxAbstract {
182+
render: ExtendedHTMLElement;
183+
private readonly instance: FormItemPillBoxAbstract;
184+
185+
constructor (props: FormItemPillBoxProps) {
186+
super();
187+
const InternalClass = Config.getInstance().config.componentClasses.FormItemPillBox ?? FormItemPillBoxInternal;
188+
this.instance = new InternalClass(props);
189+
this.render = this.instance.render;
190+
}
191+
192+
setValue = (value: string): void => {
193+
this.instance.setValue(value);
194+
};
195+
196+
getValue = (): string => {
197+
return this.instance.getValue();
198+
};
199+
200+
setEnabled = (enabled: boolean): void => {
201+
this.instance.setEnabled(enabled);
202+
};
203+
}

0 commit comments

Comments
 (0)