diff --git a/.changeset/nasty-cherries-double.md b/.changeset/nasty-cherries-double.md new file mode 100644 index 000000000..ee4eecdff --- /dev/null +++ b/.changeset/nasty-cherries-double.md @@ -0,0 +1,5 @@ +--- +"@alauda/ui": patch +--- + +feat: optimize tags input diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index cd9922771..d9831cbb6 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -7,6 +7,9 @@ on: jobs: release_beta: name: Release Beta + permissions: + contents: read + id-token: write runs-on: ubuntu-latest steps: - name: Checkout Repo @@ -17,6 +20,7 @@ jobs: with: node-version: 20 cache: yarn + registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: yarn --frozen-lockfile @@ -25,7 +29,7 @@ jobs: run: sh scripts/release.sh env: PUBLISH_VERSION: beta - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Sync Cnpm run: npx cnpm sync @alauda/ui diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 0b058c8d0..ba6dfad22 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -10,6 +10,9 @@ on: jobs: release_prod: name: Release Prod + permissions: + contents: write + id-token: write runs-on: ubuntu-latest steps: - name: Checkout Repo @@ -20,6 +23,7 @@ jobs: with: node-version: 20 cache: yarn + registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: yarn --frozen-lockfile @@ -33,7 +37,7 @@ jobs: run: sh scripts/release.sh env: PUBLISH_VERSION: ${{ github.event.inputs.version }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Sync Cnpm run: npx cnpm sync @alauda/ui diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cd835d64..0bd1a6bd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,9 @@ jobs: release: name: Release runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - name: Checkout Repo uses: actions/checkout@v3 @@ -21,6 +24,7 @@ jobs: with: node-version: 20 cache: yarn + registry-url: 'https://registry.npmjs.org' - name: Install Dependencies run: yarn --frozen-lockfile @@ -35,7 +39,7 @@ jobs: publish: yarn release env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Sync Cnpm run: npx cnpm sync @alauda/ui diff --git a/jest.setup.ts b/jest.setup.ts index 9b4d656dd..8057e984c 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,4 +1,18 @@ -import 'jest-preset-angular/setup-jest'; +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv(); + +// Suppress CSS parsing errors from JSDOM (e.g., @layer rules not supported) +const originalConsoleError = console.error; +console.error = (...args: any[]) => { + if ( + typeof args[0] === 'string' && + args[0].includes('Could not parse CSS stylesheet') + ) { + return; + } + originalConsoleError.call(console, ...args); +}; Object.assign(globalThis, { ngJest: { diff --git a/package.json b/package.json index 87e82107b..53d435448 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,10 @@ "name": "@alauda/ui", "version": "9.1.1", "description": "Angular UI components by Alauda Frontend Team.", - "repository": "git+https://github.com/alauda/alauda-ui.git", + "repository": { + "type": "git", + "url": "https://github.com/alauda/ui.git" + }, "author": "Alauda Frontend", "contributors": [ "FengTianze", diff --git a/scripts/release.sh b/scripts/release.sh index dfc85faf7..960b11c4f 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -4,12 +4,10 @@ PUBLISH_VERSION=$(node scripts/publish-version) PUBLISH_BRANCH=$(node scripts/publish-branch) NPM_TAG=$(node scripts/npm-tag) -if [ "$NPM_TOKEN" = "" ]; then +if [ "$NODE_AUTH_TOKEN" = "" ] && [ "$NPM_TOKEN" = "" ]; then echo "NPM_TOKEN is not available on PR from forked repository!" echo "If you're a member of Alauda, just checkout a new branch instead." exit 0 -else - npm set //registry.npmjs.org/:_authToken "$NPM_TOKEN" fi if [ "$NPM_TAG" = "latest" ]; then @@ -28,4 +26,4 @@ if [ "$PUBLISH_BRANCH" != "" ]; then git push --follow-tags origin "$PUBLISH_BRANCH" fi -npm publish ./release --tag "$NPM_TAG" +npm publish ./release --tag "$NPM_TAG" --provenance --access public diff --git a/src/input/tags-input/tags-input-form.component.spec.ts b/src/input/tags-input/tags-input-form.component.spec.ts index 9036a93be..da0056687 100644 --- a/src/input/tags-input/tags-input-form.component.spec.ts +++ b/src/input/tags-input/tags-input-form.component.spec.ts @@ -22,14 +22,10 @@ describe('TagsInputComponent Required Validation Behavior', () => { let fixture: ComponentFixture; 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'), @@ -64,12 +60,24 @@ describe('TagsInputComponent Required Validation Behavior', () => { inputEl.value = 'a'; inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual(['a']); + inputEl.dispatchEvent(new Event('blur')); tick(); fixture.detectChanges(); + expect(testHost.form.get('tags')!.value).toEqual(['a']); + inputEl.value = 'a'; inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual(['a', 'a']); + inputEl.dispatchEvent(new Event('blur')); tick(); fixture.detectChanges(); @@ -83,12 +91,24 @@ describe('TagsInputComponent Required Validation Behavior', () => { inputEl.value = 'a'; inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual(['a']); + inputEl.dispatchEvent(new Event('blur')); tick(); fixture.detectChanges(); + expect(testHost.form.get('tags')!.value).toEqual(['a']); + inputEl.value = 'a'; inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual(['a', 'a']); + inputEl.dispatchEvent(new Event('blur')); tick(); fixture.detectChanges(); @@ -104,6 +124,11 @@ describe('TagsInputComponent Required Validation Behavior', () => { inputEl.value = ''; inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual([]); + inputEl.dispatchEvent(new Event('blur')); tick(); fixture.detectChanges(); @@ -117,18 +142,23 @@ describe('TagsInputComponent Required Validation Behavior', () => { inputEl.value = ''; inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual([]); + inputEl.dispatchEvent(new Event('blur')); tick(); fixture.detectChanges(); - expect(testHost.form.get('tags')!.value).toEqual(['']); + 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[]; + const value = control.value as string; if (value.includes('a')) { return { patternB: true }; } @@ -138,30 +168,42 @@ describe('TagsInputComponent Required Validation Behavior', () => { inputEl.value = 'apple'; inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual(['apple']); + expect(testHost.form.valid).toBeTruthy(); + inputEl.dispatchEvent(new Event('blur')); - tick(0); + tick(); fixture.detectChanges(); - expect(tagComp.model).toEqual([]); - expect(testHost.form.get('tags')!.value).toEqual([]); + expect(testHost.form.get('tags')!.value).toEqual(['apple']); expect(testHost.form.valid).toBeFalsy(); })); it('should add tag when input passes inputValidator', fakeAsync(() => { testHost.checkFn = control => { - const value = control.value as string[]; + 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')); + fixture.detectChanges(); + tick(); + + expect(testHost.form.get('tags')!.value).toEqual(['ccc']); + expect(testHost.form.valid).toBeTruthy(); + inputEl.dispatchEvent(new Event('blur')); - tick(0); + tick(); fixture.detectChanges(); - expect(tagComp.model).toEqual(['ccc']); expect(testHost.form.get('tags')!.value).toEqual(['ccc']); expect(testHost.form.valid).toBeTruthy(); })); @@ -175,12 +217,16 @@ describe('TagsInputComponent Required Validation Behavior', () => { inputEl.value = 'bad'; inputEl.dispatchEvent(new Event('input')); - inputEl.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + tick(); + expect(testHost.form.get('tags')!.value).toEqual(['bad']); + + inputEl.dispatchEvent(new Event('blur')); tick(); fixture.detectChanges(); - expect(testHost.form.get('tags')!.value).toEqual([]); + expect(testHost.form.get('tags')!.value).toEqual(['bad']); })); it('should allow adding tag when async validator resolves to true', fakeAsync(() => { diff --git a/src/input/tags-input/tags-input.component.html b/src/input/tags-input/tags-input.component.html index 420620990..4e9c86955 100644 --- a/src/input/tags-input/tags-input.component.html +++ b/src/input/tags-input/tags-input.component.html @@ -9,18 +9,21 @@ > {{ placeholder }} - - {{ tag }} - + @for (item of model; let index = $index; track index) { + @if (!item.isInputting) { + + {{ item.value }} + + } + } { it('should push new value when press Enter', fakeAsync(() => { inputEl.value = 'app'; + inputEl.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); fixture.detectChanges(); tick(20); diff --git a/src/input/tags-input/tags-input.component.ts b/src/input/tags-input/tags-input.component.ts index 1b1156db2..55a836748 100644 --- a/src/input/tags-input/tags-input.component.ts +++ b/src/input/tags-input/tags-input.component.ts @@ -1,4 +1,3 @@ -import { NgFor } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, @@ -35,6 +34,11 @@ import { createWithMaxRowCount } from './with-max-row-count'; export const INPUT_ERROR_KEY = 'input_data_error'; +interface TagItem { + value: string; + isInputting: boolean; +} + @Component({ selector: 'aui-tags-input', templateUrl: './tags-input.component.html', @@ -46,6 +50,8 @@ export const INPUT_ERROR_KEY = 'input_data_error'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, preserveWhitespaces: false, + standalone: true, + imports: [TagComponent], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -53,10 +59,9 @@ export const INPUT_ERROR_KEY = 'input_data_error'; multi: true, }, ], - imports: [NgFor, TagComponent], }) export class TagsInputComponent - extends CommonFormControl + extends CommonFormControl implements AfterViewInit, OnChanges { bem: Bem = buildBem('aui-tags-input'); @@ -132,11 +137,12 @@ export class TagsInputComponent private readonly withMaxRowCount = createWithMaxRowCount(this); focused = false; - override model: string[] = []; - // 内置form control,仅作校验使用 + get confirmedTags(): TagItem[] { + return this.model.filter(item => !item.isInputting); + } + readonly inputControl: UntypedFormControl; - // 外层 FormControl,所有的校验逻辑针对输入数据 controlContainer: NgControl; get rootClass() { @@ -195,15 +201,34 @@ export class TagsInputComponent onRemove(index: number) { const target = this.model[index]; - if (target && this.readonlyTags.includes(target)) { + if (target && this.readonlyTags.includes(target.value)) { return; } - this.emitValue(this.model.filter((_, i) => i !== index)); + this.model = this.model.filter((_, i) => i !== index); + this.emitModel(this.model); } onInput() { const value = this.inputRef.nativeElement.value; - // make sure value sync to span element + const lastItem = this.model[this.model.length - 1]; + + if (lastItem?.isInputting) { + this.model = value + ? [...this.model.slice(0, -1), { value, isInputting: true }] + : this.model.slice(0, -1); + } else if (value) { + this.model = [...this.model, { value, isInputting: true }]; + } + + this.emitModel(this.model); + + if ( + this.controlContainer?.control?.errors?.[INPUT_ERROR_KEY] && + Object.keys(this.controlContainer.control.errors).length === 1 + ) { + this.controlContainer.control.setErrors(null); + } + requestAnimationFrame(() => { if (value.length) { this.renderer.setStyle( @@ -215,12 +240,6 @@ export class TagsInputComponent this.renderer.removeStyle(this.inputRef.nativeElement, 'width'); } }); - if ( - this.controlContainer?.control?.errors?.[INPUT_ERROR_KEY] && - Object.keys(this.controlContainer.control.errors).length === 1 - ) { - this.controlContainer.control.setErrors(null); - } } onKeyDown(event: KeyboardEvent) { @@ -233,7 +252,7 @@ export class TagsInputComponent event.stopPropagation(); event.preventDefault(); requestAnimationFrame(() => { - this.pushValue(inputEl.value); + this.confirmInput(inputEl.value); }); } } @@ -244,51 +263,88 @@ export class TagsInputComponent onInputBlur(event: Event) { this.focused = false; - this.pushValue((event.target as HTMLInputElement).value); + this.confirmInput((event.target as HTMLInputElement).value); if (this.onTouched) { this.onTouched(); } } - trackByValue(_: number, value: string) { - return value; + protected override valueIn(v: string[]): TagItem[] { + const tags = v || []; + + const items = tags.map((value, index) => ({ + value, + isInputting: + this.model?.[index]?.value === value + ? this.model[index].isInputting + : false, + })); + + if (!items.some(item => item.isInputting)) { + this.clearInput(); + } + + return this.sortByReadonly(items); + } + + protected override modelOut(model: TagItem[]): string[] { + return model.map(item => item.value); + } + + private sortByReadonly(items: TagItem[]): TagItem[] { + if (!this.readonlyTags.length) { + return items; + } + const readonlySet = new Set(this.readonlyTags); + const readonlyItems: TagItem[] = []; + const normalItems: TagItem[] = []; + + items.forEach(item => { + if (readonlySet.has(item.value)) { + readonlyItems.push(item); + } else { + normalItems.push(item); + } + }); + + return [...readonlyItems, ...normalItems]; } - protected override valueIn(v: string[]): string[] { + private removeInputtingItem() { + this.model = this.model.filter(item => !item.isInputting); this.clearInput(); - return this.sortByReadonly(v || []); + this.emitModel(this.model); } - private sortByReadonly(items: string[]) { - return this.readonlyTags.length - ? [ - ...items.reduce( - (acc, curr) => acc.add(curr), - new Set(this.readonlyTags), - ), - ] - : items; + private confirmInputtingItem() { + this.model = this.model.map(item => + item.isInputting ? { value: item.value, isInputting: false } : item, + ); + this.clearInput(); + this.emitModel(this.model); } - private pushValue(value: string) { - if (!this.allowEmpty && !value) { + private confirmInput(value: string) { + if ( + (!this.allowEmpty && !value) || + (!this.allowRepeat && + this.confirmedTags.some(item => item.value === value)) + ) { this.removeInputControlError(); + this.removeInputtingItem(); return; } - if (!this.allowRepeat && this.model.includes(value)) { - return; - } - this.inputControl.setValue(this.inputRef.nativeElement.value); - // inputControl 自身的状态为同步计算 + + this.inputControl.setValue(value); this.syncControlStatus(); + if (this.inputControl.valid) { - this.emitValue(this.model.concat(value)); + this.confirmInputtingItem(); } else if (this.inputControl.pending) { - // PENDING 后只会变为 VALID 或 INVALID 的决议状态 this.inputControl.statusChanges.pipe(take(1)).subscribe(_ => { this.syncControlStatus(); if (this.inputControl.valid) { - this.emitValue(this.model.concat(value)); + this.confirmInputtingItem(); } }); } @@ -308,7 +364,6 @@ export class TagsInputComponent [INPUT_ERROR_KEY]: errors, }); } else if (disabled) { - // 与当前 input 校验脱离 this.controlContainer?.control?.updateValueAndValidity(); } }