diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/add-item/demo.component.html b/apps/code-examples/src/app/code-examples/lookup/lookup/add-item/demo.component.html index 74a389599f..293f6c5cc2 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/add-item/demo.component.html +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/add-item/demo.component.html @@ -15,10 +15,10 @@ formControlName="favoriteNames" idProperty="name" [enableShowMore]="true" - (searchAsync)="searchAsync($event)" [showAddButton]="true" [showMoreConfig]="showMoreConfig" (addClick)="addClick($event)" + (searchAsync)="searchAsync($event)" />
- - - - -
- Form model: -
{{ favoritesForm.value | json }}
-
- - diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/async/demo.component.spec.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/async/demo.component.spec.ts deleted file mode 100644 index fc8c35b894..0000000000 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/async/demo.component.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { SkyInputBoxHarness } from '@skyux/forms/testing'; -import { SkyLookupHarness } from '@skyux/lookup/testing'; - -import { of } from 'rxjs'; - -import { DemoComponent } from './demo.component'; -import { DemoService } from './demo.service'; - -describe('Lookup asynchronous search demo', () => { - let mockSvc!: jasmine.SpyObj; - - async function setupTest(): Promise<{ - lookupHarness: SkyLookupHarness; - fixture: ComponentFixture; - }> { - const fixture = TestBed.createComponent(DemoComponent); - const loader = TestbedHarnessEnvironment.loader(fixture); - - const lookupHarness = await ( - await loader.getHarness( - SkyInputBoxHarness.with({ dataSkyId: 'favorite-names-field' }), - ) - ).queryHarness(SkyLookupHarness); - - return { lookupHarness, fixture }; - } - - beforeEach(() => { - // Create a mock search service. In a real-world application, the search - // service would make a web request which should be avoided in unit tests. - mockSvc = jasmine.createSpyObj('DemoService', ['search']); - - TestBed.configureTestingModule({ - imports: [DemoComponent, NoopAnimationsModule], - providers: [ - { - provide: DemoService, - useValue: mockSvc, - }, - ], - }); - }); - - it('should set the expected initial value', async () => { - const { lookupHarness } = await setupTest(); - - await expectAsync(lookupHarness.getSelectionsText()).toBeResolvedTo([ - 'Shirley', - ]); - }); - - it('should update the form control when a favorite name is selected', async () => { - const { lookupHarness, fixture } = await setupTest(); - - mockSvc.search.and.callFake((searchText) => - of({ - hasMore: false, - people: - searchText === 'b' - ? [ - { - name: 'Bernard', - }, - ] - : [], - totalCount: 1, - }), - ); - - await lookupHarness.enterText('b'); - await lookupHarness.selectSearchResult({ - text: 'Bernard', - }); - - expect(fixture.componentInstance.favoritesForm.value.favoriteNames).toEqual( - [{ name: 'Shirley' }, { name: 'Bernard' }], - ); - }); - - it('should respect the selection descriptor', async () => { - const { lookupHarness } = await setupTest(); - - mockSvc.search.and.callFake(() => - of({ - hasMore: false, - people: [ - { - id: '21', - name: 'Bernard', - }, - ], - totalCount: 1, - }), - ); - - await lookupHarness.clickShowMoreButton(); - - const picker = await lookupHarness.getShowMorePicker(); - - await expectAsync(picker.getSearchAriaLabel()).toBeResolvedTo( - 'Search names', - ); - await expectAsync(picker.getSaveButtonAriaLabel()).toBeResolvedTo( - 'Select names', - ); - }); -}); diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/async/demo.component.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/async/demo.component.ts deleted file mode 100644 index 1fd8d8d5b5..0000000000 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/async/demo.component.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; -import { - AbstractControl, - FormBuilder, - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, - ValidationErrors, -} from '@angular/forms'; -import { SkyFormErrorModule, SkyInputBoxModule } from '@skyux/forms'; -import { SkyWaitService } from '@skyux/indicators'; -import { - SkyAutocompleteSearchAsyncArgs, - SkyLookupAddClickEventArgs, - SkyLookupModule, - SkyLookupShowMoreConfig, -} from '@skyux/lookup'; - -import { map } from 'rxjs/operators'; - -import { DemoService } from './demo.service'; -import { Person } from './person'; - -@Component({ - standalone: true, - selector: 'app-demo', - templateUrl: './demo.component.html', - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - SkyFormErrorModule, - SkyInputBoxModule, - SkyLookupModule, - ], -}) -export class DemoComponent implements OnInit { - public favoritesForm: FormGroup<{ - favoriteNames: FormControl; - }>; - - public showMoreConfig: SkyLookupShowMoreConfig = { - nativePickerConfig: { - selectionDescriptor: 'names', - }, - }; - - readonly #svc = inject(DemoService); - readonly #waitSvc = inject(SkyWaitService); - - constructor() { - const names = new FormControl([{ name: 'Shirley' }], { - validators: [ - (control: AbstractControl): ValidationErrors => { - if ( - control.value?.some((person: Person) => !person.name.match(/e/i)) - ) { - return { letterE: true }; - } - - return {}; - }, - ], - }); - - this.favoritesForm = inject(FormBuilder).group({ - favoriteNames: names, - }); - } - - public ngOnInit(): void { - // If you need to execute some logic after the lookup values change, - // subscribe to Angular's built-in value changes observable. - this.favoritesForm.valueChanges.subscribe((changes) => { - console.log('Lookup value changes:', changes); - }); - } - - protected onSubmit(): void { - alert('Form submitted with: ' + JSON.stringify(this.favoritesForm.value)); - } - - protected searchAsync(args: SkyAutocompleteSearchAsyncArgs): void { - // In a real-world application the search service might return an Observable - // created by calling HttpClient.get(). Assigning that Observable to the result - // allows the lookup component to cancel the web request if it does not complete - // before the user searches again. - args.result = this.#svc.search(args.searchText).pipe( - map((result) => ({ - hasMore: result.hasMore, - items: result.people, - totalCount: result.totalCount, - })), - ); - } - - protected addClick(args: SkyLookupAddClickEventArgs): void { - const person: Person = { - name: 'Newman', - }; - - this.#waitSvc.blockingWrap(this.#svc.addPerson(person)).subscribe(() => { - args.itemAdded({ - item: person, - }); - }); - } -} diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/async/person.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/async/person.ts deleted file mode 100644 index 3763f53ace..0000000000 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/async/person.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Person { - name: string; -} diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.html b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.html index 1335134c00..b31a0a9ae9 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.html +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.html @@ -13,12 +13,11 @@ >
{ + let mockSvc!: jasmine.SpyObj; + async function setupTest(): Promise<{ lookupHarness: SkyLookupHarness; fixture: ComponentFixture; @@ -25,8 +114,18 @@ describe('Lookup custom picker demo', () => { } beforeEach(() => { + // Create a mock search service. In a real-world application, the search + // service would make a web request which should be avoided in unit tests. + mockSvc = jasmine.createSpyObj('DemoService', ['search']); + TestBed.configureTestingModule({ imports: [DemoComponent, NoopAnimationsModule], + providers: [ + { + provide: DemoService, + useValue: mockSvc, + }, + ], }); }); @@ -41,6 +140,19 @@ describe('Lookup custom picker demo', () => { it('should update the form control when a favorite name is selected', async () => { const { lookupHarness, fixture } = await setupTest(); + mockSvc.search.and.callFake(() => + of({ + hasMore: false, + people: [ + { + name: 'Abed', + formal: 'Mr. Nadir', + }, + ], + totalCount: 1, + }), + ); + await lookupHarness.enterText('Be'); const allResultHarnesses = await lookupHarness.getSearchResults(); @@ -61,6 +173,14 @@ describe('Lookup custom picker demo', () => { it('should use a custom picker', async () => { const { lookupHarness, fixture } = await setupTest(); + mockSvc.search.and.callFake(() => + of({ + hasMore: false, + people: people, + totalCount: 20, + }), + ); + // Show the custom picker. await lookupHarness.clickShowMoreButton(); diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.ts index 62129758b7..e3ee7018c4 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.component.ts @@ -8,14 +8,19 @@ import { ReactiveFormsModule, } from '@angular/forms'; import { SkyInputBoxModule } from '@skyux/forms'; +import { SkyWaitService } from '@skyux/indicators'; import { - SkyAutocompleteSearchFunctionFilter, + SkyAutocompleteSearchAsyncArgs, + SkyLookupAddClickEventArgs, SkyLookupModule, SkyLookupShowMoreConfig, SkyLookupShowMoreCustomPickerContext, } from '@skyux/lookup'; import { SkyModalService } from '@skyux/modals'; +import { map } from 'rxjs/operators'; + +import { DemoService } from './demo.service'; import { Person } from './person'; import { PickerModalComponent } from './picker-modal.component'; @@ -37,107 +42,23 @@ export class DemoComponent implements OnInit { }>; protected showMoreConfig: SkyLookupShowMoreConfig; - protected searchFilters: SkyAutocompleteSearchFunctionFilter[]; - - protected people: Person[] = [ - { - name: 'Abed', - formal: 'Mr. Nadir', - }, - { - name: 'Alex', - formal: 'Mr. Osbourne', - }, - { - name: 'Ben', - formal: 'Mr. Chang', - }, - { - name: 'Britta', - formal: 'Ms. Perry', - }, - { - name: 'Buzz', - formal: 'Mr. Hickey', - }, - { - name: 'Craig', - formal: 'Mr. Pelton', - }, - { - name: 'Elroy', - formal: 'Mr. Patashnik', - }, - { - name: 'Garrett', - formal: 'Mr. Lambert', - }, - { - name: 'Ian', - formal: 'Mr. Duncan', - }, - { - name: 'Jeff', - formal: 'Mr. Winger', - }, - { - name: 'Leonard', - formal: 'Mr. Rodriguez', - }, - { - name: 'Neil', - formal: 'Mr. Neil', - }, - { - name: 'Pierce', - formal: 'Mr. Hawthorne', - }, - { - name: 'Preston', - formal: 'Mr. Koogler', - }, - { - name: 'Rachel', - formal: 'Ms. Rachel', - }, - { - name: 'Shirley', - formal: 'Ms. Bennett', - }, - { - name: 'Todd', - formal: 'Mr. Jacobson', - }, - { - name: 'Troy', - formal: 'Mr. Barnes', - }, - { - name: 'Vaughn', - formal: 'Mr. Miller', - }, - { - name: 'Vicki', - formal: 'Ms. Jenkins', - }, - ]; readonly #modalSvc = inject(SkyModalService); + readonly #svc = inject(DemoService); + readonly #waitSvc = inject(SkyWaitService); constructor() { + const names = new FormControl([ + { + name: 'Shirley', + formal: 'Ms. Bennett', + }, + ]); + this.favoritesForm = inject(FormBuilder).group({ - favoriteNames: [[this.people[15]]], + favoriteNames: names, }); - this.searchFilters = [ - (_, item: Person): boolean => { - const names = this.favoritesForm.value.favoriteNames; - - // Only show people in the search results that have not been chosen already. - return !names?.some((option) => option.name === item.name); - }, - ]; - this.showMoreConfig = { customPicker: { open: (context): void => { @@ -171,8 +92,31 @@ export class DemoComponent implements OnInit { }); } - protected onAddButtonClicked(): void { - alert('Add button clicked!'); + protected searchAsync(args: SkyAutocompleteSearchAsyncArgs): void { + // In a real-world application the search service might return an Observable + // created by calling HttpClient.get(). Assigning that Observable to the result + // allows the lookup component to cancel the web request if it does not complete + // before the user searches again. + args.result = this.#svc.search(args.searchText).pipe( + map((result) => ({ + hasMore: result.hasMore, + items: result.people, + totalCount: result.totalCount, + })), + ); + } + + protected addClick(args: SkyLookupAddClickEventArgs): void { + const person: Person = { + name: 'Newman', + formal: 'Mr. Parker', + }; + + this.#waitSvc.blockingWrap(this.#svc.addPerson(person)).subscribe(() => { + args.itemAdded({ + item: person, + }); + }); } protected onSubmit(): void { diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.service.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.service.ts new file mode 100644 index 0000000000..e26d31eb38 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/demo.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; + +import { Person } from './person'; +import { LookupAsyncDemoSearchResults } from './search-results'; + +const people: Person[] = [ + { + name: 'Abed', + formal: 'Mr. Nadir', + }, + { + name: 'Alex', + formal: 'Mr. Osbourne', + }, + { + name: 'Ben', + formal: 'Mr. Chang', + }, + { + name: 'Britta', + formal: 'Ms. Perry', + }, + { + name: 'Buzz', + formal: 'Mr. Hickey', + }, + { + name: 'Craig', + formal: 'Mr. Pelton', + }, + { + name: 'Elroy', + formal: 'Mr. Patashnik', + }, + { + name: 'Garrett', + formal: 'Mr. Lambert', + }, + { + name: 'Ian', + formal: 'Mr. Duncan', + }, + { + name: 'Jeff', + formal: 'Mr. Winger', + }, + { + name: 'Leonard', + formal: 'Mr. Rodriguez', + }, + { + name: 'Neil', + formal: 'Mr. Neil', + }, + { + name: 'Pierce', + formal: 'Mr. Hawthorne', + }, + { + name: 'Preston', + formal: 'Mr. Koogler', + }, + { + name: 'Rachel', + formal: 'Ms. Rachel', + }, + { + name: 'Shirley', + formal: 'Ms. Bennett', + }, + { + name: 'Todd', + formal: 'Mr. Jacobson', + }, + { + name: 'Troy', + formal: 'Mr. Barnes', + }, + { + name: 'Vaughn', + formal: 'Mr. Miller', + }, + { + name: 'Vicki', + formal: 'Ms. Jenkins', + }, +]; + +@Injectable({ + providedIn: 'root', +}) +export class DemoService { + public search(searchText: string): Observable { + // Simulate a network call with latency. A real-world application might + // use Angular's HttpClient to create an Observable from a call to a + // web service. + searchText = searchText.toUpperCase(); + + const matchingPeople = people.filter((person) => + person.name.toUpperCase().includes(searchText), + ); + + return of({ + hasMore: false, + people: matchingPeople, + totalCount: matchingPeople.length, + }).pipe(delay(800)); + } + + public addPerson(person: Person): Observable { + // Simulate adding a person with a network call. + if (!people.some((item) => item.name === person.name)) { + people.unshift(person); + } + + return of(1).pipe(delay(800)); + } +} diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/picker-modal.component.html b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/picker-modal.component.html index 02b66f7e46..b8924f54ef 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/picker-modal.component.html +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/custom-picker/picker-modal.component.html @@ -1,26 +1,32 @@ Names -
- - - - - {{ people[i].name }} - - - {{ people[i].formal }} - - - - -
+ @if (peopleForm) { +
+ + @for ( + personControl of peopleForm.controls.people.controls; + track personControl; + let i = $index + ) { + + + + {{ people[i].name }} + + + {{ people[i].formal }} + + + + } + +
+ } @else { +
+ +
+ }
- + {{ item.name }}
@@ -40,7 +40,7 @@
- + {{ item.name }}
diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.spec.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.spec.ts index 299d95a56e..52766d3f18 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.spec.ts +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.spec.ts @@ -4,10 +4,15 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SkyInputBoxHarness } from '@skyux/forms/testing'; import { SkyLookupHarness } from '@skyux/lookup/testing'; +import { of } from 'rxjs'; + import { DemoComponent } from './demo.component'; +import { DemoService } from './demo.service'; import { ItemHarness } from './item-harness'; describe('Lookup result templates demo', () => { + let mockSvc!: jasmine.SpyObj; + async function setupTest(): Promise<{ lookupHarness: SkyLookupHarness; fixture: ComponentFixture; @@ -25,8 +30,18 @@ describe('Lookup result templates demo', () => { } beforeEach(() => { + // Create a mock search service. In a real-world application, the search + // service would make a web request which should be avoided in unit tests. + mockSvc = jasmine.createSpyObj('DemoService', ['search']); + TestBed.configureTestingModule({ imports: [DemoComponent, NoopAnimationsModule], + providers: [ + { + provide: DemoService, + useValue: mockSvc, + }, + ], }); }); @@ -41,6 +56,19 @@ describe('Lookup result templates demo', () => { it('should use the expected dropdown item template', async () => { const { lookupHarness } = await setupTest(); + mockSvc.search.and.callFake(() => + of({ + hasMore: false, + people: [ + { + name: 'Abed', + formal: 'Mr. Nadir', + }, + ], + totalCount: 1, + }), + ); + await lookupHarness.enterText('be'); const results = await lookupHarness.getSearchResults(); @@ -56,6 +84,19 @@ describe('Lookup result templates demo', () => { it('should use the expected modal item template', async () => { const { lookupHarness } = await setupTest(); + mockSvc.search.and.callFake(() => + of({ + hasMore: false, + people: [ + { + name: 'Abed', + formal: 'Mr. Nadir', + }, + ], + totalCount: 1, + }), + ); + await lookupHarness.clickShowMoreButton(); const pickerHarness = await lookupHarness.getShowMorePicker(); @@ -74,6 +115,19 @@ describe('Lookup result templates demo', () => { it('should update the form control when a favorite name is selected', async () => { const { lookupHarness, fixture } = await setupTest(); + mockSvc.search.and.callFake(() => + of({ + hasMore: false, + people: [ + { + name: 'Abed', + formal: 'Mr. Nadir', + }, + ], + totalCount: 1, + }), + ); + await lookupHarness.enterText('be'); const allResultHarnesses = await lookupHarness.getSearchResults(); @@ -91,6 +145,19 @@ describe('Lookup result templates demo', () => { it('should respect the selection descriptor', async () => { const { lookupHarness } = await setupTest(); + mockSvc.search.and.callFake(() => + of({ + hasMore: false, + people: [ + { + name: 'Abed', + formal: 'Mr. Nadir', + }, + ], + totalCount: 1, + }), + ); + await lookupHarness.clickShowMoreButton(); const picker = await lookupHarness.getShowMorePicker(); diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.ts index e7c0066051..a02cf57a81 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.component.ts @@ -14,12 +14,17 @@ import { ReactiveFormsModule, } from '@angular/forms'; import { SkyInputBoxModule } from '@skyux/forms'; +import { SkyWaitService } from '@skyux/indicators'; import { - SkyAutocompleteSearchFunctionFilter, + SkyAutocompleteSearchAsyncArgs, + SkyLookupAddClickEventArgs, SkyLookupModule, SkyLookupShowMoreConfig, } from '@skyux/lookup'; +import { map } from 'rxjs/operators'; + +import { DemoService } from './demo.service'; import { Person } from './person'; @Component({ @@ -39,91 +44,6 @@ export class DemoComponent implements OnInit { favoriteNames: FormControl; }>; - protected searchFilters: SkyAutocompleteSearchFunctionFilter[]; - - protected people: Person[] = [ - { - name: 'Abed', - formal: 'Mr. Nadir', - }, - { - name: 'Alex', - formal: 'Mr. Osbourne', - }, - { - name: 'Ben', - formal: 'Mr. Chang', - }, - { - name: 'Britta', - formal: 'Ms. Perry', - }, - { - name: 'Buzz', - formal: 'Mr. Hickey', - }, - { - name: 'Craig', - formal: 'Mr. Pelton', - }, - { - name: 'Elroy', - formal: 'Mr. Patashnik', - }, - { - name: 'Garrett', - formal: 'Mr. Lambert', - }, - { - name: 'Ian', - formal: 'Mr. Duncan', - }, - { - name: 'Jeff', - formal: 'Mr. Winger', - }, - { - name: 'Leonard', - formal: 'Mr. Rodriguez', - }, - { - name: 'Neil', - formal: 'Mr. Neil', - }, - { - name: 'Pierce', - formal: 'Mr. Hawthorne', - }, - { - name: 'Preston', - formal: 'Mr. Koogler', - }, - { - name: 'Rachel', - formal: 'Ms. Rachel', - }, - { - name: 'Shirley', - formal: 'Ms. Bennett', - }, - { - name: 'Todd', - formal: 'Mr. Jacobson', - }, - { - name: 'Troy', - formal: 'Mr. Barnes', - }, - { - name: 'Vaughn', - formal: 'Mr. Miller', - }, - { - name: 'Vicki', - formal: 'Ms. Jenkins', - }, - ]; - protected showMoreConfig: SkyLookupShowMoreConfig = { nativePickerConfig: { selectionDescriptor: 'names', @@ -139,19 +59,20 @@ export class DemoComponent implements OnInit { } } + readonly #svc = inject(DemoService); + readonly #waitSvc = inject(SkyWaitService); + constructor() { + const names = new FormControl([ + { + name: 'Shirley', + formal: 'Ms. Bennett', + }, + ]); + this.favoritesForm = inject(FormBuilder).group({ - favoriteNames: [[this.people[15]]], + favoriteNames: names, }); - - this.searchFilters = [ - (_, item: Person): boolean => { - const names = this.favoritesForm.value.favoriteNames; - - // Only show people in the search results that have not been chosen already. - return !names?.some((option) => option.name === item.name); - }, - ]; } public ngOnInit(): void { @@ -162,8 +83,31 @@ export class DemoComponent implements OnInit { }); } - protected onAddButtonClicked(): void { - alert('Add button clicked!'); + protected searchAsync(args: SkyAutocompleteSearchAsyncArgs): void { + // In a real-world application the search service might return an Observable + // created by calling HttpClient.get(). Assigning that Observable to the result + // allows the lookup component to cancel the web request if it does not complete + // before the user searches again. + args.result = this.#svc.search(args.searchText).pipe( + map((result) => ({ + hasMore: result.hasMore, + items: result.people, + totalCount: result.totalCount, + })), + ); + } + + protected addClick(args: SkyLookupAddClickEventArgs): void { + const person: Person = { + name: 'Newman', + formal: 'Mr. Parker', + }; + + this.#waitSvc.blockingWrap(this.#svc.addPerson(person)).subscribe(() => { + args.itemAdded({ + item: person, + }); + }); } protected onSubmit(): void { diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.service.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.service.ts new file mode 100644 index 0000000000..e26d31eb38 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/demo.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; + +import { Person } from './person'; +import { LookupAsyncDemoSearchResults } from './search-results'; + +const people: Person[] = [ + { + name: 'Abed', + formal: 'Mr. Nadir', + }, + { + name: 'Alex', + formal: 'Mr. Osbourne', + }, + { + name: 'Ben', + formal: 'Mr. Chang', + }, + { + name: 'Britta', + formal: 'Ms. Perry', + }, + { + name: 'Buzz', + formal: 'Mr. Hickey', + }, + { + name: 'Craig', + formal: 'Mr. Pelton', + }, + { + name: 'Elroy', + formal: 'Mr. Patashnik', + }, + { + name: 'Garrett', + formal: 'Mr. Lambert', + }, + { + name: 'Ian', + formal: 'Mr. Duncan', + }, + { + name: 'Jeff', + formal: 'Mr. Winger', + }, + { + name: 'Leonard', + formal: 'Mr. Rodriguez', + }, + { + name: 'Neil', + formal: 'Mr. Neil', + }, + { + name: 'Pierce', + formal: 'Mr. Hawthorne', + }, + { + name: 'Preston', + formal: 'Mr. Koogler', + }, + { + name: 'Rachel', + formal: 'Ms. Rachel', + }, + { + name: 'Shirley', + formal: 'Ms. Bennett', + }, + { + name: 'Todd', + formal: 'Mr. Jacobson', + }, + { + name: 'Troy', + formal: 'Mr. Barnes', + }, + { + name: 'Vaughn', + formal: 'Mr. Miller', + }, + { + name: 'Vicki', + formal: 'Ms. Jenkins', + }, +]; + +@Injectable({ + providedIn: 'root', +}) +export class DemoService { + public search(searchText: string): Observable { + // Simulate a network call with latency. A real-world application might + // use Angular's HttpClient to create an Observable from a call to a + // web service. + searchText = searchText.toUpperCase(); + + const matchingPeople = people.filter((person) => + person.name.toUpperCase().includes(searchText), + ); + + return of({ + hasMore: false, + people: matchingPeople, + totalCount: matchingPeople.length, + }).pipe(delay(800)); + } + + public addPerson(person: Person): Observable { + // Simulate adding a person with a network call. + if (!people.some((item) => item.name === person.name)) { + people.unshift(person); + } + + return of(1).pipe(delay(800)); + } +} diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/search-results.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/search-results.ts new file mode 100644 index 0000000000..855f953a41 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/result-templates/search-results.ts @@ -0,0 +1,7 @@ +import { Person } from './person'; + +export interface LookupAsyncDemoSearchResults { + hasMore: boolean; + people: Person[]; + totalCount: number; +} diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.html b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.html index fc53631b7f..cb2be33728 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.html +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.html @@ -1,11 +1,11 @@
+ @if (favoritesForm.controls.favoriteName.errors?.['letterE']) { + + }
{ + let mockSvc!: jasmine.SpyObj; -describe('Lookup single select demo', () => { async function setupTest(): Promise<{ lookupHarness: SkyLookupHarness; fixture: ComponentFixture; @@ -16,7 +21,7 @@ describe('Lookup single select demo', () => { const lookupHarness = await ( await loader.getHarness( - SkyInputBoxHarness.with({ dataSkyId: 'favorite-name-field' }), + SkyInputBoxHarness.with({ dataSkyId: 'favorite-names-field' }), ) ).queryHarness(SkyLookupHarness); @@ -24,8 +29,18 @@ describe('Lookup single select demo', () => { } beforeEach(() => { + // Create a mock search service. In a real-world application, the search + // service would make a web request which should be avoided in unit tests. + mockSvc = jasmine.createSpyObj('DemoService', ['search']); + TestBed.configureTestingModule({ imports: [DemoComponent, NoopAnimationsModule], + providers: [ + { + provide: DemoService, + useValue: mockSvc, + }, + ], }); }); @@ -38,28 +53,56 @@ describe('Lookup single select demo', () => { it('should update the form control when a favorite name is selected', async () => { const { lookupHarness, fixture } = await setupTest(); - await lookupHarness.enterText('be'); + mockSvc.search.and.callFake((searchText) => + of({ + hasMore: false, + people: + searchText === 'b' + ? [ + { + name: 'Bernard', + }, + ] + : [], + totalCount: 1, + }), + ); + + await lookupHarness.enterText('b'); await lookupHarness.selectSearchResult({ - text: 'Ben', + text: 'Bernard', }); expect(fixture.componentInstance.favoritesForm.value.favoriteName).toEqual([ - { name: 'Ben' }, + { name: 'Bernard' }, ]); }); it('should respect the selection descriptor', async () => { const { lookupHarness } = await setupTest(); + mockSvc.search.and.callFake(() => + of({ + hasMore: false, + people: [ + { + id: '21', + name: 'Bernard', + }, + ], + totalCount: 1, + }), + ); + await lookupHarness.clickShowMoreButton(); const picker = await lookupHarness.getShowMorePicker(); await expectAsync(picker.getSearchAriaLabel()).toBeResolvedTo( - 'Search name', + 'Search names', ); await expectAsync(picker.getSaveButtonAriaLabel()).toBeResolvedTo( - 'Select name', + 'Select names', ); }); }); diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.ts index f7796240cd..45ae15fcbd 100644 --- a/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.component.ts @@ -1,19 +1,26 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; import { + AbstractControl, FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, + ValidationErrors, } from '@angular/forms'; -import { SkyInputBoxModule } from '@skyux/forms'; +import { SkyFormErrorModule, SkyInputBoxModule } from '@skyux/forms'; +import { SkyWaitService } from '@skyux/indicators'; import { - SkyAutocompleteSearchFunctionFilter, + SkyAutocompleteSearchAsyncArgs, + SkyLookupAddClickEventArgs, SkyLookupModule, SkyLookupShowMoreConfig, } from '@skyux/lookup'; +import { map } from 'rxjs/operators'; + +import { DemoService } from './demo.service'; import { Person } from './person'; @Component({ @@ -24,6 +31,7 @@ import { Person } from './person'; CommonModule, FormsModule, ReactiveFormsModule, + SkyFormErrorModule, SkyInputBoxModule, SkyLookupModule, ], @@ -35,50 +43,31 @@ export class DemoComponent implements OnInit { public showMoreConfig: SkyLookupShowMoreConfig = { nativePickerConfig: { - selectionDescriptor: 'name', + selectionDescriptor: 'names', }, }; - protected searchFilters: SkyAutocompleteSearchFunctionFilter[]; + readonly #svc = inject(DemoService); + readonly #waitSvc = inject(SkyWaitService); - protected people: Person[] = [ - { name: 'Abed' }, - { name: 'Alex' }, - { name: 'Ben' }, - { name: 'Britta' }, - { name: 'Buzz' }, - { name: 'Craig' }, - { name: 'Elroy' }, - { name: 'Garrett' }, - { name: 'Ian' }, - { name: 'Jeff' }, - { name: 'Leonard' }, - { name: 'Neil' }, - { name: 'Pierce' }, - { name: 'Preston' }, - { name: 'Rachel' }, - { name: 'Shirley' }, - { name: 'Todd' }, - { name: 'Troy' }, - { name: 'Vaughn' }, - { name: 'Vicki' }, - ]; + constructor() { + const name = new FormControl([{ name: 'Shirley' }], { + validators: [ + (control: AbstractControl): ValidationErrors => { + if ( + control.value?.some((person: Person) => !person.name.match(/e/i)) + ) { + return { letterE: true }; + } - protected name: Person[] = [this.people[15]]; + return {}; + }, + ], + }); - constructor() { this.favoritesForm = inject(FormBuilder).group({ - favoriteName: [[this.people[15]]], + favoriteName: name, }); - - this.searchFilters = [ - (_, item: Person): boolean => { - const names = this.favoritesForm.value.favoriteName; - - // Only show people in the search results that have not been chosen already. - return !names?.some((option) => option.name === item.name); - }, - ]; } public ngOnInit(): void { @@ -89,11 +78,33 @@ export class DemoComponent implements OnInit { }); } - protected onAddButtonClicked(): void { - alert('Add button clicked!'); - } - protected onSubmit(): void { alert('Form submitted with: ' + JSON.stringify(this.favoritesForm.value)); } + + protected searchAsync(args: SkyAutocompleteSearchAsyncArgs): void { + // In a real-world application the search service might return an Observable + // created by calling HttpClient.get(). Assigning that Observable to the result + // allows the lookup component to cancel the web request if it does not complete + // before the user searches again. + args.result = this.#svc.search(args.searchText).pipe( + map((result) => ({ + hasMore: result.hasMore, + items: result.people, + totalCount: result.totalCount, + })), + ); + } + + protected addClick(args: SkyLookupAddClickEventArgs): void { + const person: Person = { + name: 'Newman', + }; + + this.#waitSvc.blockingWrap(this.#svc.addPerson(person)).subscribe(() => { + args.itemAdded({ + item: person, + }); + }); + } } diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.service.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.service.ts new file mode 100644 index 0000000000..e4e5bd0427 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/demo.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; + +import { Person } from './person'; +import { LookupAsyncDemoSearchResults } from './search-results'; + +const people: Person[] = [ + { name: 'Abed' }, + { name: 'Alex' }, + { name: 'Ben' }, + { name: 'Britta' }, + { name: 'Buzz' }, + { name: 'Craig' }, + { name: 'Elroy' }, + { name: 'Garrett' }, + { name: 'Ian' }, + { name: 'Jeff' }, + { name: 'Leonard' }, + { name: 'Neil' }, + { name: 'Pierce' }, + { name: 'Preston' }, + { name: 'Rachel' }, + { name: 'Shirley' }, + { name: 'Todd' }, + { name: 'Troy' }, + { name: 'Vaughn' }, + { name: 'Vicki' }, +]; + +@Injectable({ + providedIn: 'root', +}) +export class DemoService { + public search(searchText: string): Observable { + // Simulate a network call with latency. A real-world application might + // use Angular's HttpClient to create an Observable from a call to a + // web service. + searchText = searchText.toUpperCase(); + + const matchingPeople = people.filter((person) => + person.name.toUpperCase().includes(searchText), + ); + + return of({ + hasMore: false, + people: matchingPeople, + totalCount: matchingPeople.length, + }).pipe(delay(800)); + } + + public addPerson(person: Person): Observable { + // Simulate adding a person with a network call. + if (!people.some((item) => item.name === person.name)) { + people.unshift(person); + } + + return of(1).pipe(delay(800)); + } +} diff --git a/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/search-results.ts b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/search-results.ts new file mode 100644 index 0000000000..855f953a41 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/lookup/lookup/single-select/search-results.ts @@ -0,0 +1,7 @@ +import { Person } from './person'; + +export interface LookupAsyncDemoSearchResults { + hasMore: boolean; + people: Person[]; + totalCount: number; +} diff --git a/apps/code-examples/src/app/features/lookup.module.ts b/apps/code-examples/src/app/features/lookup.module.ts index fa91b99a34..d1b0ac16f9 100644 --- a/apps/code-examples/src/app/features/lookup.module.ts +++ b/apps/code-examples/src/app/features/lookup.module.ts @@ -44,13 +44,6 @@ const routes: Routes = [ (c) => c.DemoComponent, ), }, - { - path: 'lookup/async', - loadComponent: () => - import('../code-examples/lookup/lookup/async/demo.component').then( - (c) => c.DemoComponent, - ), - }, { path: 'lookup/custom-picker', loadComponent: () => diff --git a/apps/code-examples/src/app/home/home.component.html b/apps/code-examples/src/app/home/home.component.html index 59a9f93572..667a3d967f 100644 --- a/apps/code-examples/src/app/home/home.component.html +++ b/apps/code-examples/src/app/home/home.component.html @@ -490,9 +490,8 @@ Lookup