Skip to content

Commit f19754d

Browse files
committed
mgr/dashboard: add text-label-list component
Introduces new form input component to input a list of strings based on carbon's cds-text-label component Fixes: https://tracker.ceph.com/issues/73319 Signed-off-by: Pedro Gonzalez Gomez <[email protected]>
1 parent 4d2729d commit f19754d

File tree

4 files changed

+212
-0
lines changed

4 files changed

+212
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@for (value of values; track $index) {
2+
<cds-text-label [helperText]="helperText && $index === values.length - 1 ? helperText : null"
3+
i18n-helperText
4+
i18n>
5+
<div class="cds-input-group">
6+
<input cdsText
7+
type="text"
8+
[placeholder]="placeholder"
9+
[value]="value"
10+
(input)="onInputChange($index, $event.target.value)"/>
11+
@if ($index > 0 ) {
12+
<cds-icon-button kind="ghost"
13+
(click)="deleteInput($index)"
14+
size="md">
15+
<cd-icon type="trash"></cd-icon>
16+
</cds-icon-button>
17+
}
18+
</div>
19+
{{$index === 0 ? label : ''}}
20+
</cds-text-label>
21+
}

src/pybind/mgr/dashboard/frontend/src/app/shared/components/text-label-list/text-label-list.component.scss

Whitespace-only changes.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { TextLabelListComponent } from './text-label-list.component';
3+
import { By } from '@angular/platform-browser';
4+
5+
describe('TextLabelListComponent', () => {
6+
let component: TextLabelListComponent;
7+
let fixture: ComponentFixture<TextLabelListComponent>;
8+
9+
beforeEach(async () => {
10+
await TestBed.configureTestingModule({
11+
imports: [TextLabelListComponent]
12+
}).compileComponents();
13+
14+
fixture = TestBed.createComponent(TextLabelListComponent);
15+
component = fixture.componentInstance;
16+
component.registerOnChange(jasmine.createSpy('onChange'));
17+
fixture.detectChanges();
18+
});
19+
20+
it('should create', () => {
21+
expect(component).toBeTruthy();
22+
});
23+
24+
it('should call writeValue and render values', () => {
25+
component.writeValue(['foo', 'bar']);
26+
fixture.detectChanges();
27+
28+
const inputs = fixture.debugElement.queryAll(By.css('cds-text-label input'));
29+
expect(inputs.length).toBe(3);
30+
expect(inputs[0].nativeElement.value).toBe('foo');
31+
expect(inputs[1].nativeElement.value).toBe('bar');
32+
expect(inputs[2].nativeElement.value).toBe('');
33+
});
34+
35+
it('should call writeValue empty and render one', () => {
36+
component.writeValue([]);
37+
fixture.detectChanges();
38+
39+
const inputs = fixture.debugElement.queryAll(By.css('cds-text-label input'));
40+
expect(inputs.length).toBe(1);
41+
expect(inputs[0].nativeElement.value).toBe('');
42+
});
43+
44+
it('should call onTouch on input changes', () => {
45+
spyOn(component as any, 'onTouched');
46+
47+
component.onInputChange(0, 'foo');
48+
49+
expect((component as any).onTouched).toHaveBeenCalled();
50+
});
51+
52+
it('should update the value at the given index', () => {
53+
component.writeValue(['foo', '']);
54+
component.onInputChange(2, 'bar');
55+
56+
expect(component.values[2]).toBe('bar');
57+
});
58+
59+
it('should return non empty values on input changes', () => {
60+
component.writeValue(['foo', 'bar', '']);
61+
component.onInputChange(3, 'test');
62+
fixture.detectChanges();
63+
64+
const inputs = fixture.debugElement.queryAll(By.css('cds-text-label input'));
65+
expect(inputs.length).toBe(5);
66+
expect((component as any).onChange).toHaveBeenCalledWith(['foo', 'bar', 'test']);
67+
});
68+
69+
it('should remove the item at the given index', () => {
70+
component.writeValue(['foo', 'bar']);
71+
component.deleteInput(0);
72+
73+
expect(component['values']).toEqual(['bar', '']);
74+
expect(component['onChange']).toHaveBeenCalledWith(['bar']);
75+
});
76+
77+
it('should add an empty input if all items are deleted', () => {
78+
component.writeValue(['foo']);
79+
component.deleteInput(0);
80+
81+
expect(component['values']).toEqual(['']);
82+
expect(component['onChange']).toHaveBeenCalledWith([]);
83+
});
84+
85+
it('should update values correctly on deletion', () => {
86+
component.writeValue(['foo', 'bar', 'test']);
87+
component.deleteInput(1);
88+
89+
expect(component['values']).toEqual(['foo', 'test', '']);
90+
expect(component['onChange']).toHaveBeenCalledWith(['foo', 'test']);
91+
});
92+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Component, Input } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
4+
import { ButtonModule, GridModule, IconModule, InputModule } from 'carbon-components-angular';
5+
import { ComponentsModule } from '../components.module';
6+
7+
@Component({
8+
selector: 'cd-text-label-list',
9+
standalone: true,
10+
imports: [
11+
CommonModule,
12+
ReactiveFormsModule,
13+
InputModule,
14+
IconModule,
15+
ButtonModule,
16+
GridModule,
17+
ComponentsModule
18+
],
19+
templateUrl: './text-label-list.component.html',
20+
styleUrl: './text-label-list.component.scss',
21+
providers: [
22+
{
23+
provide: NG_VALUE_ACCESSOR,
24+
useExisting: TextLabelListComponent,
25+
multi: true
26+
}
27+
]
28+
})
29+
export class TextLabelListComponent implements ControlValueAccessor {
30+
@Input()
31+
label: string = '';
32+
@Input()
33+
helperText: string = '';
34+
@Input()
35+
placeholder: string = '';
36+
37+
values: string[] = [''];
38+
disabled: boolean = false;
39+
touched: boolean = false;
40+
41+
private onChange: (value: string[]) => void = () => {};
42+
private onTouched: () => void = () => {};
43+
44+
private _isRemovingChar(originalValue: string, value: string): boolean {
45+
return originalValue.slice(0, -1) === value;
46+
}
47+
48+
writeValue(value: string[]): void {
49+
this.values = value?.length ? [...value, ''] : [''];
50+
}
51+
52+
registerOnChange(onChange: (value: string[]) => void): void {
53+
this.onChange = onChange;
54+
}
55+
56+
registerOnTouched(onTouched: () => void): void {
57+
this.onTouched = onTouched;
58+
}
59+
60+
setDisabledState(disabled: boolean): void {
61+
this.disabled = disabled;
62+
}
63+
64+
onInputChange(index: number, value: string) {
65+
const originalValue = this.values[index];
66+
this.values[index] = value;
67+
68+
if (
69+
!this._isRemovingChar(originalValue, value) &&
70+
index === this.values.length - 1 &&
71+
value.trim() !== ''
72+
) {
73+
this.values.push('');
74+
}
75+
76+
const nonEmptyValues = this.values.filter((v) => v.trim() !== '');
77+
this.onChange(nonEmptyValues.length ? nonEmptyValues : []);
78+
79+
this.markAsTouched();
80+
}
81+
82+
markAsTouched() {
83+
if (!this.touched) {
84+
this.onTouched();
85+
this.touched = true;
86+
}
87+
}
88+
89+
deleteInput(index: number) {
90+
this.values.splice(index, 1);
91+
92+
if (this.values.length === 0) {
93+
this.values.push('');
94+
}
95+
96+
const nonEmpty = this.values.filter((v) => v.trim() !== '');
97+
this.onChange(nonEmpty.length ? nonEmpty : []);
98+
}
99+
}

0 commit comments

Comments
 (0)