Skip to content

Commit 6fc0297

Browse files
authored
feat: update combo-box to account for 'create new' autocomplete use cases (#854)
* feat: update combo-box to account for 'create new' autocomplete use cases * fix: import * fix: naming and filter optimization
1 parent a4e07b8 commit 6fc0297

File tree

7 files changed

+331
-59
lines changed

7 files changed

+331
-59
lines changed

projects/common/src/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export * from './custom-error/custom-error';
2929

3030
// DOM
3131
export { DomElementMeasurerService } from './utilities/dom/dom-element-measurer.service';
32+
export { DomElementScrollIntoViewService } from './utilities/dom/dom-element-scroll-into-view.service';
3233
export * from './utilities/dom/dom-utilities';
3334

3435
// External
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Injectable } from '@angular/core';
2+
3+
@Injectable({ providedIn: 'root' })
4+
export class DomElementScrollIntoViewService {
5+
public scrollIntoView(element?: HTMLElement): void {
6+
// Basically just here so we can polyfill this in tests
7+
return element?.scrollIntoView();
8+
}
9+
}

projects/common/src/utilities/formatters/string/highlight.pipe.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ export class HighlightPipe implements PipeTransform {
1212
return snippetsToHighlight.reduce((highlightedText, highlightConfig) => {
1313
const highlightHtmlTag = getHtmlTagForHighlightType(highlightConfig.highlightType);
1414

15-
return highlightedText.replace(highlightConfig.text, `<${highlightHtmlTag}>$&</${highlightHtmlTag}>`);
15+
return highlightedText.replace(
16+
new RegExp(highlightConfig.text, 'ig'),
17+
`<${highlightHtmlTag}>$&</${highlightHtmlTag}>`
18+
);
1619
}, fullText);
1720
}
1821
}

projects/components/src/combo-box/combo-box-api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export interface ComboBoxOption<TValue = string> {
22
text: string;
3-
value: TValue;
3+
value?: TValue;
4+
icon?: string;
45
tooltip?: string;
56
}
67

projects/components/src/combo-box/combo-box.component.scss

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
@import 'mixins';
22

3+
.combo-box {
4+
display: flex;
5+
height: inherit;
6+
width: inherit;
7+
}
8+
39
.popover-trigger {
410
display: flex;
5-
height: 24px;
11+
align-items: center;
12+
height: inherit;
13+
width: inherit;
614
border: 1px solid $color-border;
715
border-radius: 6px;
816

@@ -54,7 +62,7 @@
5462
background: inherit;
5563
width: 100%;
5664
border: none;
57-
margin-left: 2px; // Need this so that the border radius of container isn't cut off when no icons
65+
margin-left: 12px; // Need this so that the border radius of container isn't cut off when no icons
5866
outline: none;
5967
}
6068

@@ -70,6 +78,7 @@
7078
color: $gray-3;
7179
cursor: pointer;
7280
visibility: hidden;
81+
margin-right: 8px;
7382

7483
&.has-text {
7584
visibility: visible;
@@ -91,16 +100,29 @@
91100

92101
.popover-content {
93102
@include dropdown();
103+
display: flex;
104+
flex-direction: column;
105+
height: 100%;
106+
overflow-y: hidden;
94107
max-height: 400px;
95108

109+
.option-list {
110+
overflow-y: auto;
111+
height: 100%;
112+
}
113+
96114
.popover-item {
97115
height: 36px;
98-
padding: 2px 8px;
116+
padding: 2px 12px;
99117
cursor: pointer;
100118

101119
display: flex;
102120
align-items: center;
103121

122+
.option-icon {
123+
padding-right: 10px;
124+
}
125+
104126
&:hover {
105127
background: $gray-1;
106128
}
@@ -109,4 +131,13 @@
109131
background: $blue-1;
110132
}
111133
}
134+
135+
.option-divider {
136+
border-top: 1px solid $gray-2;
137+
margin: 0 12px;
138+
}
139+
140+
.create-option {
141+
min-height: 36px;
142+
}
112143
}

projects/components/src/combo-box/combo-box.component.test.ts

Lines changed: 167 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { HttpClientTestingModule } from '@angular/common/http/testing';
22
import { fakeAsync, flush } from '@angular/core/testing';
33
import { IconLibraryTestingModule } from '@hypertrace/assets-library';
4-
import { NavigationService } from '@hypertrace/common';
5-
import { createHostFactory, mockProvider, SpectatorHost } from '@ngneat/spectator/jest';
4+
import { DomElementScrollIntoViewService, NavigationService } from '@hypertrace/common';
5+
import { createHostFactory, mockProvider } from '@ngneat/spectator/jest';
66
import { EMPTY } from 'rxjs';
77
import { ComboBoxOption } from './combo-box-api';
88
import { ComboBoxComponent } from './combo-box.component';
@@ -17,37 +17,52 @@ describe('Combo Box component', () => {
1717
mockProvider(NavigationService, {
1818
navigation$: EMPTY,
1919
navigateWithinApp: jest.fn()
20-
})
20+
}),
21+
mockProvider(DomElementScrollIntoViewService)
2122
]
2223
});
2324

24-
let spectator: SpectatorHost<ComboBoxComponent>;
25-
2625
const comboBoxOptions: ComboBoxOption[] = [
27-
{ text: 'first-text', value: 'first-value' },
28-
{ text: 'second-text', value: 'second-value' },
29-
{ text: 'third-text', value: 'third-value' }
26+
{ text: 'First-text', value: 'first-value' },
27+
{ text: 'Second-Text', value: 'second-value' },
28+
{ text: 'Third-TEXT', value: 'third-value' }
3029
];
3130

32-
beforeEach(fakeAsync(() => {
33-
spectator = createHost(`<ht-combo-box [options]="options" [text]="text"></ht-combo-box>`, {
34-
hostProps: {
35-
options: comboBoxOptions,
36-
text: 'test-text'
31+
test('should display and not notify for initial value', fakeAsync(() => {
32+
const spectator = createHost(
33+
`
34+
<ht-combo-box [options]="options" [text]="text"></ht-combo-box>
35+
`,
36+
{
37+
hostProps: {
38+
options: comboBoxOptions,
39+
text: 'test-text'
40+
}
3741
}
38-
});
42+
);
3943
spectator.tick();
40-
}));
4144

42-
test('should display and not notify for initial value', () => {
4345
spyOn(spectator.component.textChange, 'emit');
4446
const element = spectator.query('.trigger-input');
4547

4648
expect(element).toHaveValue('test-text');
4749
expect(spectator.component.textChange.emit).not.toHaveBeenCalled();
48-
});
50+
}));
4951

5052
test('should clear input and notify', fakeAsync(() => {
53+
const spectator = createHost(
54+
`
55+
<ht-combo-box [options]="options" [text]="text"></ht-combo-box>
56+
`,
57+
{
58+
hostProps: {
59+
options: comboBoxOptions,
60+
text: 'test-text'
61+
}
62+
}
63+
);
64+
spectator.tick();
65+
5166
spyOn(spectator.component.clear, 'emit');
5267
const element = spectator.query('.trigger-input');
5368
expect(element).toHaveValue('test-text');
@@ -62,6 +77,19 @@ describe('Combo Box component', () => {
6277
}));
6378

6479
test('should escape from popover and notify on second escape', fakeAsync(() => {
80+
const spectator = createHost(
81+
`
82+
<ht-combo-box [options]="options" [text]="text"></ht-combo-box>
83+
`,
84+
{
85+
hostProps: {
86+
options: comboBoxOptions,
87+
text: 'test-text'
88+
}
89+
}
90+
);
91+
spectator.tick();
92+
6593
spyOn(spectator.component.escape, 'emit');
6694
const trigger = spectator.query('.trigger-input');
6795
expect(trigger).toHaveValue('test-text');
@@ -78,7 +106,50 @@ describe('Combo Box component', () => {
78106
flush();
79107
}));
80108

81-
test('should autocomplete value', fakeAsync(() => {
109+
test('should autocomplete value when provideCreateOption disabled', fakeAsync(() => {
110+
const spectator = createHost(
111+
`
112+
<ht-combo-box [options]="options" [text]="text"></ht-combo-box>
113+
`,
114+
{
115+
hostProps: {
116+
options: comboBoxOptions,
117+
text: 'test-text'
118+
}
119+
}
120+
);
121+
spectator.tick();
122+
123+
spyOn(spectator.component.textChange, 'emit');
124+
spectator.click('.popover-trigger');
125+
spectator.tick();
126+
127+
const element = spectator.query('.trigger-input');
128+
spectator.typeInElement('th', element!);
129+
element?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
130+
spectator.tick();
131+
132+
expect(spectator.component.text).toEqual('Third-TEXT');
133+
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('Third-TEXT');
134+
135+
flush();
136+
}));
137+
138+
test('should create new value when provideCreateOption enabled', fakeAsync(() => {
139+
const spectator = createHost(
140+
`
141+
<ht-combo-box [options]="options" [text]="text" [provideCreateOption]="provideCreateOption"></ht-combo-box>
142+
`,
143+
{
144+
hostProps: {
145+
options: comboBoxOptions,
146+
text: 'test-text',
147+
provideCreateOption: true
148+
}
149+
}
150+
);
151+
spectator.tick();
152+
82153
spyOn(spectator.component.textChange, 'emit');
83154
spectator.click('.popover-trigger');
84155
spectator.tick();
@@ -88,15 +159,68 @@ describe('Combo Box component', () => {
88159
element?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
89160
spectator.tick();
90161

91-
expect(spectator.component.text).toEqual('third-text');
92-
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('third-text');
162+
expect(spectator.component.text).toEqual('th'); // Creates instead of autocompletes first match
163+
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('th');
164+
165+
flush();
166+
}));
167+
168+
test('should display and notify for tabbed to value when wrapping list without createOption', fakeAsync(() => {
169+
const spectator = createHost(
170+
`
171+
<ht-combo-box [options]="options" [text]="text" [provideCreateOption]="provideCreateOption"></ht-combo-box>
172+
`,
173+
{
174+
hostProps: {
175+
options: comboBoxOptions,
176+
text: 'test-text',
177+
provideCreateOption: false
178+
}
179+
}
180+
);
181+
182+
spyOn(spectator.component.textChange, 'emit');
183+
spyOn(spectator.component.enter, 'emit');
184+
spectator.click('.trigger-clear-button'); // Need to clear the 'test-text' to unfilter all options
185+
spectator.click('.popover-trigger');
186+
spectator.tick();
187+
188+
const element = spectator.query('.trigger-input');
189+
element!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
190+
element!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
191+
element!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }));
192+
element!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
193+
element!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
194+
element!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
195+
spectator.tick();
196+
197+
expect(spectator.component.text).toEqual('Second-Text');
198+
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('Second-Text');
199+
expect(spectator.component.enter.emit).toHaveBeenCalledWith({
200+
text: 'Second-Text',
201+
option: { text: 'Second-Text', value: 'second-value' }
202+
});
93203

94204
flush();
95205
}));
96206

97-
test('should display and notify for tabbed to value', fakeAsync(() => {
207+
test('should display and notify for tabbed to value when wrapping list with createOption', fakeAsync(() => {
208+
const spectator = createHost(
209+
`
210+
<ht-combo-box [options]="options" [text]="text" [provideCreateOption]="provideCreateOption"></ht-combo-box>
211+
`,
212+
{
213+
hostProps: {
214+
options: comboBoxOptions,
215+
text: 'test-text',
216+
provideCreateOption: true
217+
}
218+
}
219+
);
220+
98221
spyOn(spectator.component.textChange, 'emit');
99222
spyOn(spectator.component.enter, 'emit');
223+
spectator.click('.trigger-clear-button'); // Need to clear the 'test-text' to unfilter all options
100224
spectator.click('.popover-trigger');
101225
spectator.tick();
102226

@@ -109,31 +233,45 @@ describe('Combo Box component', () => {
109233
element!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
110234
spectator.tick();
111235

112-
expect(spectator.component.text).toEqual('second-text');
113-
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('second-text');
236+
expect(spectator.component.text).toEqual('First-text');
237+
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('First-text');
114238
expect(spectator.component.enter.emit).toHaveBeenCalledWith({
115-
text: 'second-text',
116-
option: { text: 'second-text', value: 'second-value' }
239+
text: 'First-text',
240+
option: { text: 'First-text', value: 'first-value' }
117241
});
118242

119243
flush();
120244
}));
121245

122246
test('should display and notify for clicked value', fakeAsync(() => {
247+
const spectator = createHost(
248+
`
249+
<ht-combo-box [options]="options" [text]="text" [provideCreateOption]="provideCreateOption"></ht-combo-box>
250+
`,
251+
{
252+
hostProps: {
253+
options: comboBoxOptions,
254+
text: 'test-text',
255+
provideCreateOption: true
256+
}
257+
}
258+
);
259+
123260
spyOn(spectator.component.textChange, 'emit');
124261
spyOn(spectator.component.selection, 'emit');
262+
spectator.click('.trigger-clear-button'); // Need to clear the 'test-text' to unfilter all options
125263
spectator.click('.popover-trigger');
126264
spectator.tick();
127265

128266
const elements = spectator.queryAll('.popover-item', { root: true });
129267
spectator.click(elements[2]);
130268
spectator.tick();
131269

132-
expect(spectator.component.text).toEqual('third-text');
133-
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('third-text');
270+
expect(spectator.component.text).toEqual('Third-TEXT');
271+
expect(spectator.component.textChange.emit).toHaveBeenCalledWith('Third-TEXT');
134272
expect(spectator.component.selection.emit).toHaveBeenCalledWith({
135-
text: 'third-text',
136-
option: { text: 'third-text', value: 'third-value' }
273+
text: 'Third-TEXT',
274+
option: { text: 'Third-TEXT', value: 'third-value' }
137275
});
138276

139277
flush();

0 commit comments

Comments
 (0)