Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/happy-planets-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@alauda/ui": patch
---

fix: optimize tags input
4 changes: 3 additions & 1 deletion src/input/tags-input/mixin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

@mixin component-style($block) {
.#{$block} {
display: inline-block;
display: flex;
flex-wrap: wrap;
position: relative;
width: 100%;
color: use-text-color(main);
Expand Down Expand Up @@ -115,6 +116,7 @@
}

.aui-tag#{&}__input {
flex: 1 0 auto;
margin-right: 0;
padding: 0;
width: 1em;
Expand Down
232 changes: 232 additions & 0 deletions src/input/tags-input/tags-input-form.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { Component } from '@angular/core';
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing';
import {
ReactiveFormsModule,
FormControl,
Validators,
FormGroup,
ValidatorFn,
AbstractControl,
AsyncValidatorFn,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
import { TagsInputComponent } from './tags-input.component';
import { of } from 'rxjs';

describe('TagsInputComponent Required Validation Behavior', () => {
let fixture: ComponentFixture<TestFormComponent>;
let testHost: TestFormComponent;
let inputEl: HTMLInputElement;
let tagComp: TagsInputComponent;

beforeEach(() => {
fixture = TestBed.createComponent(TestFormComponent);
fixture.detectChanges();
tagComp = fixture.debugElement.query(
By.directive(TagsInputComponent),
).componentInstance;
testHost = fixture.componentInstance;
const el = fixture.debugElement.query(
By.css('.aui-tags-input'),
).nativeElement;
inputEl = el.querySelector('input');
});

// it('should mark form valid when input has value but tag is not confirmed yet', fakeAsync(() => {
// expect(testHost.form.valid).toBeFalsy();

// inputEl.value = 'hello';
// inputEl.dispatchEvent(new Event('input'));
// fixture.detectChanges();
// tick();
// fixture.detectChanges();

// expect(testHost.form.valid).toBeTruthy();

// inputEl.dispatchEvent(new Event('blur'));
// fixture.detectChanges();
// tick();
// fixture.detectChanges();

// expect(testHost.form.valid).toBeTruthy();
// expect(testHost.form.get('tags')!.value).toEqual(['hello']);
// }));

describe('allowRepeat Behavior', () => {
it('should NOT allow duplicate tags when allowRepeat = false', fakeAsync(() => {
testHost.allowRepeat = false;
fixture.detectChanges();

inputEl.value = 'a';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick();
fixture.detectChanges();

inputEl.value = 'a';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick();
fixture.detectChanges();

expect(testHost.form.get('tags')!.value).toEqual(['a']);
}));

it('should allow duplicate tags when allowRepeat = true', fakeAsync(() => {
testHost.allowRepeat = true;
fixture.detectChanges();

inputEl.value = 'a';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick();
fixture.detectChanges();

inputEl.value = 'a';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick();
fixture.detectChanges();

expect(testHost.form.get('tags')!.value).toEqual(['a', 'a']);
}));
});

describe('allowEmpty Behavior', () => {
it('should NOT add empty tag when allowEmpty = false', fakeAsync(() => {
testHost.allowEmpty = false;
fixture.detectChanges();

inputEl.value = '';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick();
fixture.detectChanges();

expect(testHost.form.get('tags')!.value).toEqual([]);
}));

it('should add empty tag when allowEmpty = true', fakeAsync(() => {
testHost.allowEmpty = true;
fixture.detectChanges();

inputEl.value = '';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick();
fixture.detectChanges();

expect(testHost.form.get('tags')!.value).toEqual(['']);
}));
});

describe('inputValidator behavior', () => {
it('should NOT add tag when input does NOT pass inputValidator', fakeAsync(() => {
testHost.checkFn = control => {
const value = control.value as string[];
if (value.includes('a')) {
return { patternB: true };
}
return null;
};
fixture.detectChanges();

inputEl.value = 'apple';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick(0);
fixture.detectChanges();

expect(tagComp.model).toEqual([]);
expect(testHost.form.get('tags')!.value).toEqual([]);
expect(testHost.form.valid).toBeFalsy();
}));
Comment thread
zChanges marked this conversation as resolved.

it('should add tag when input passes inputValidator', fakeAsync(() => {
testHost.checkFn = control => {
const value = control.value as string[];
if (value.includes('a')) {
return { patternB: true };
}
return null;
};
inputEl.value = 'ccc';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick(0);
fixture.detectChanges();
Comment on lines +150 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add missing change detection after setting validator.

After setting testHost.checkFn (line 151), the test should call fixture.detectChanges() before proceeding to set inputEl.value (line 158). This ensures the input binding [inputValidator]="checkFn" is updated. The first test (lines 129-148) correctly includes this step at line 137, but it's missing here.

Apply this diff:

       };
+      fixture.detectChanges();
+
       inputEl.value = 'ccc';
       inputEl.dispatchEvent(new Event('input'));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should add tag when input passes inputValidator', fakeAsync(() => {
testHost.checkFn = control => {
const value = control.value as string[];
if (value.includes('a')) {
return { patternB: true };
}
return null;
};
inputEl.value = 'ccc';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick(0);
fixture.detectChanges();
it('should add tag when input passes inputValidator', fakeAsync(() => {
testHost.checkFn = control => {
const value = control.value as string[];
if (value.includes('a')) {
return { patternB: true };
}
return null;
};
fixture.detectChanges();
inputEl.value = 'ccc';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));
tick(0);
fixture.detectChanges();
🤖 Prompt for AI Agents
In src/input/tags-input/tags-input-form.component.spec.ts around lines 150 to
162, after setting testHost.checkFn you must call fixture.detectChanges() so the
[inputValidator]="checkFn" binding updates before setting inputEl.value; add a
fixture.detectChanges() immediately after assigning testHost.checkFn and before
dispatching the input/blur events to ensure the validator is applied.


expect(tagComp.model).toEqual(['ccc']);
expect(testHost.form.get('tags')!.value).toEqual(['ccc']);
expect(testHost.form.valid).toBeTruthy();
}));
});

describe('inputAsyncValidator behavior', () => {
it('should block adding tag when async validator resolves to false', fakeAsync(() => {
testHost.inputAsyncValidator = (_control: AbstractControl) =>
of({ tagAsyncInvalid: true });
fixture.detectChanges();

inputEl.value = 'bad';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));

tick();
fixture.detectChanges();

expect(testHost.form.get('tags')!.value).toEqual([]);
}));

it('should allow adding tag when async validator resolves to true', fakeAsync(() => {
testHost.inputAsyncValidator = (_control: AbstractControl) =>
Promise.resolve(null);
fixture.detectChanges();

inputEl.value = 'hello';
inputEl.dispatchEvent(new Event('input'));
inputEl.dispatchEvent(new Event('blur'));

tick();
fixture.detectChanges();

expect(testHost.form.get('tags')!.value).toEqual(['hello']);
}));
});
});

@Component({
template: `
<form [formGroup]="form">
<aui-tags-input
required
formControlName="tags"
[allowRepeat]="allowRepeat"
[allowEmpty]="allowEmpty"
[inputValidator]="checkFn"
[inputAsyncValidator]="inputAsyncValidator"
></aui-tags-input>
</form>
`,
imports: [ReactiveFormsModule, TagsInputComponent],
})
class TestFormComponent {
allowRepeat = false;
allowEmpty = false;

inputAsyncValidator: AsyncValidatorFn = (_control: AbstractControl) =>
Promise.resolve(null);

checkFn: ValidatorFn = () => {
return null;
};

form = new FormGroup({
tags: new FormControl<string[]>([], Validators.required),
});
}
1 change: 0 additions & 1 deletion src/input/tags-input/tags-input.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<div
[class]="rootClass"
(mousedown)="$event.preventDefault()"
(click)="inputRef.focus()"
[style.max-height]="maxHeight"
>
Expand Down
35 changes: 32 additions & 3 deletions stories/input/tags-input.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, ValidatorFn } from '@angular/forms';
import { FormControl, ValidatorFn, Validators } from '@angular/forms';

import { ComponentSize } from '@alauda/ui';

Expand All @@ -9,7 +9,6 @@ import { ComponentSize } from '@alauda/ui';
<aui-tags-input
[size]="size"
[formControl]="control"
[inputValidator]="checkFn"
[clearable]="true"
[allowRepeat]="allowRepeat"
[allowEmpty]="allowEmpty"
Expand All @@ -33,12 +32,35 @@ import { ComponentSize } from '@alauda/ui';
[allowEmpty]="allowEmpty"
placeholder="placeholder"
></aui-tags-input>
<br />
<br />

<div>多行 tags maxRowCount: {{ maxRowCount }}</div>
<aui-tags-input
style="width: 400px;"
[size]="size"
[formControl]="rowsControl"
[inputValidator]="checkFn"
[maxRowCount]="maxRowCount"
[clearable]="true"
[readonlyTags]="['service']"
[allowRepeat]="allowRepeat"
[allowEmpty]="allowEmpty"
placeholder="placeholder"
></aui-tags-input>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class TagsInputComponent {
value = ['app', 'service'];
rowsValue = [
'app123456789123456789123456789',
'service',
'db23456789123456789123456789',
'zxcvbnmasdfghjkqwertyuiop',
];
maxRowCount = 2;
pattern = /^a/;
sizeOptions = {
[ComponentSize.Large]: ComponentSize.Large,
Expand All @@ -55,7 +77,14 @@ export class TagsInputComponent {
return null;
};

control = new FormControl(this.value, { validators: [this.checkArrFn] });
control = new FormControl(this.value, {
validators: [Validators.required, this.checkArrFn],
});

rowsControl = new FormControl(this.rowsValue, {
validators: [this.checkArrFn],
});

size = ComponentSize.Medium;
allowRepeat = true;
allowEmpty = false;
Expand Down