diff --git a/packages/components-dev/username/module.ts b/packages/components-dev/username/module.ts index 6b2f797952..2bdc6ab94f 100644 --- a/packages/components-dev/username/module.ts +++ b/packages/components-dev/username/module.ts @@ -9,6 +9,8 @@ import { UsernameExamplesModule } from '../../docs-examples/components/username' + + `, changeDetection: ChangeDetectionStrategy.OnPush }) diff --git a/packages/components/username/__snapshots__/username.spec.ts.snap b/packages/components/username/__snapshots__/username.spec.ts.snap index 1146e8d025..20a6169e87 100644 --- a/packages/components/username/__snapshots__/username.spec.ts.snap +++ b/packages/components/username/__snapshots__/username.spec.ts.snap @@ -18,3 +18,17 @@ exports[`KbqUsernamePipe should format full name using custom format 1`] = ` "Alice B. C.", ] `; + +exports[`kbqBuildUsernameText should append login after name 1`] = `"Root M. A. mroot"`; + +exports[`kbqBuildUsernameText should include site without login 1`] = `"Root M. A. (corp)"`; + +exports[`kbqBuildUsernameText should return name only when login and site are absent 1`] = `"Root M. A."`; + +exports[`kbqBuildUsernameText should skip empty name and join remaining parts 1`] = `"mroot (corp)"`; + +exports[`kbqBuildUsernameText should use custom formatLogin 1`] = `"Root M. A. [@mroot]"`; + +exports[`kbqBuildUsernameText should use custom formatSite 1`] = `"Root M. A. corp"`; + +exports[`kbqBuildUsernameText should wrap site in parentheses by default 1`] = `"Root M. A. mroot (corp)"`; diff --git a/packages/components/username/username.en.md b/packages/components/username/username.en.md index b7841f1233..5d20de148f 100644 --- a/packages/components/username/username.en.md +++ b/packages/components/username/username.en.md @@ -21,3 +21,19 @@ To format the full name, use the `kbqUsernameCustom` pipe with a format string a The component can be conveniently used inside links. To visually match the link style, set the `inherit` style — this ensures that color and appearance are inherited from the parent element. + +### Search and highlight + +To filter a list of users by the displayed value, inject `KbqUsernamePipe` as a service and call its `transform` method — it returns the same formatted string that `kbq-username` renders by default. + +To highlight the matched substring, use `kbq-username-custom-view` together with the `kbqHighlightBackground` pipe. + + + +#### Usage in filter-bar + +The same approach works inside `kbq-pipe-select`. Use `valueTemplate` to render `kbq-username` as an option label. The search text is available via `pipe.searchControl.value` — `pipe` is the `$implicit` template context, which is the pipe component instance itself. + +Set `item.name` to the formatted name (used as the trigger display value) and `item.searchKey` to include login and site so the built-in option filter covers all displayed fields. + + diff --git a/packages/components/username/username.pipe.ts b/packages/components/username/username.pipe.ts index 88b89642ec..9a98030064 100644 --- a/packages/components/username/username.pipe.ts +++ b/packages/components/username/username.pipe.ts @@ -6,6 +6,7 @@ import { KbqMappingMissingError } from './constants'; import { KbqFormatKeyToProfileMapping, KbqFormatKeyToProfileMappingExtended, KbqUsernameFormatKey } from './types'; +import { KbqUserInfo } from './username'; @Injectable({ providedIn: 'root' }) @Pipe({ @@ -95,3 +96,31 @@ export class KbqUsernameCustomPipe implements PipeTransform { return result.trim(); } } + +export interface KbqUsernameTextOptions { + /** Formats the login segment. Defaults to identity. */ + formatLogin?: (login: string) => string; + /** + * Formats the site segment. + * Defaults to wrapping in parentheses, matching kbq-username display. + */ + formatSite?: (site: string) => string; +} + +/** + * Builds a full username string from a pre-formatted name plus optional login and site, + * mirroring the text rendered by `kbq-username`. + * + * Provide custom `formatLogin` / `formatSite` to tailor the output. + */ +export function kbqBuildUsernameText( + data: { name: string } & Partial>, + options?: KbqUsernameTextOptions +): string { + const formatLogin = options?.formatLogin ?? ((login) => login); + const formatSite = options?.formatSite ?? ((site) => `(${site})`); + + return [data.name, data.login && formatLogin(data.login), data.site && formatSite(data.site)] + .filter(Boolean) + .join(' '); +} diff --git a/packages/components/username/username.ru.md b/packages/components/username/username.ru.md index f38196d4ea..539649d9fd 100644 --- a/packages/components/username/username.ru.md +++ b/packages/components/username/username.ru.md @@ -17,3 +17,19 @@ Компонент удобно использовать внутри ссылок. Чтобы он визуально соответствовал стилю ссылки, установите стиль `inherit` — в этом случае цвет и оформление будут унаследованы от родительского элемента. + +### Поиск и подсветка + +Чтобы фильтровать список пользователей по отображаемому значению, используйте `KbqUsernamePipe` как сервис. Метод `transform` возвращает ту же строку, которую по умолчанию формирует `kbq-username`. + +Для подсветки найденной подстроки используйте `kbq-username-custom-view` вместе с пайпом `kbqHighlightBackground`. + + + +#### Использование в filter-bar + +Тот же подход работает внутри `kbq-pipe-select`. Используйте `valueTemplate` для рендеринга `kbq-username` в качестве лейбла опции. Текст поиска доступен через `pipe.searchControl.value` — `pipe` является `$implicit`-контекстом шаблона, то есть экземпляром компонента пайпа. + +Задайте `item.name` форматированным именем (используется как отображаемое значение в триггере), а `item.searchKey` — строкой, включающей логин и сайт, чтобы встроенный фильтр опций охватывал все отображаемые поля. + + diff --git a/packages/components/username/username.spec.ts b/packages/components/username/username.spec.ts index 19ecd024fe..0b5923ea88 100644 --- a/packages/components/username/username.spec.ts +++ b/packages/components/username/username.spec.ts @@ -9,7 +9,7 @@ import { KbqUsernameStyle } from './types'; import { KbqUsername, KbqUsernameCustomView } from './username'; -import { KbqUsernameCustomPipe, KbqUsernamePipe } from './username.pipe'; +import { kbqBuildUsernameText, KbqUsernameCustomPipe, KbqUsernamePipe } from './username.pipe'; const createComponent = (component: Type, providers: any[] = []): ComponentFixture => { TestBed.configureTestingModule({ imports: [component], providers }).compileComponents(); @@ -120,6 +120,38 @@ describe(KbqUsernamePipe.name, () => { }); }); +describe('kbqBuildUsernameText', () => { + it('should return name only when login and site are absent', () => { + expect(kbqBuildUsernameText({ name: 'Root M. A.' })).toMatchSnapshot(); + }); + + it('should append login after name', () => { + expect(kbqBuildUsernameText({ name: 'Root M. A.', login: 'mroot' })).toMatchSnapshot(); + }); + + it('should wrap site in parentheses by default', () => { + expect(kbqBuildUsernameText({ name: 'Root M. A.', login: 'mroot', site: 'corp' })).toMatchSnapshot(); + }); + + it('should include site without login', () => { + expect(kbqBuildUsernameText({ name: 'Root M. A.', site: 'corp' })).toMatchSnapshot(); + }); + + it('should use custom formatSite', () => { + expect(kbqBuildUsernameText({ name: 'Root M. A.', site: 'corp' }, { formatSite: (s) => s })).toMatchSnapshot(); + }); + + it('should use custom formatLogin', () => { + expect( + kbqBuildUsernameText({ name: 'Root M. A.', login: 'mroot' }, { formatLogin: (s) => `[@${s}]` }) + ).toMatchSnapshot(); + }); + + it('should skip empty name and join remaining parts', () => { + expect(kbqBuildUsernameText({ name: '', login: 'mroot', site: 'corp' })).toMatchSnapshot(); + }); +}); + describe(KbqUsername.name, () => { it('should use default input values', () => { const { debugElement } = createComponent(TestComponent); diff --git a/packages/docs-examples/components/username/index.ts b/packages/docs-examples/components/username/index.ts index 4240651915..5f9e36c7d2 100644 --- a/packages/docs-examples/components/username/index.ts +++ b/packages/docs-examples/components/username/index.ts @@ -1,16 +1,27 @@ import { NgModule } from '@angular/core'; import { UsernameAsLinkExample } from './username-as-link/username-as-link-example'; import { UsernameCustomExample } from './username-custom/username-custom-example'; +import { UsernameFilterBarOptionExample } from './username-filter-bar-option/username-filter-bar-option-example'; import { UsernameOverviewExample } from './username-overview/username-overview-example'; import { UsernamePlaygroundExample } from './username-playground/username-playground-example'; +import { UsernameSearchExample } from './username-search/username-search-example'; -export { UsernameAsLinkExample, UsernameCustomExample, UsernameOverviewExample, UsernamePlaygroundExample }; +export { + UsernameAsLinkExample, + UsernameCustomExample, + UsernameFilterBarOptionExample, + UsernameOverviewExample, + UsernamePlaygroundExample, + UsernameSearchExample +}; const EXAMPLES = [ UsernameCustomExample, UsernameOverviewExample, UsernamePlaygroundExample, - UsernameAsLinkExample + UsernameAsLinkExample, + UsernameSearchExample, + UsernameFilterBarOptionExample ]; @NgModule({ diff --git a/packages/docs-examples/components/username/username-filter-bar-option/username-filter-bar-option-example.ts b/packages/docs-examples/components/username/username-filter-bar-option/username-filter-bar-option-example.ts new file mode 100644 index 0000000000..3a067ee43e --- /dev/null +++ b/packages/docs-examples/components/username/username-filter-bar-option/username-filter-bar-option-example.ts @@ -0,0 +1,91 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, inject, TemplateRef, viewChild } from '@angular/core'; +import { KbqHighlightBackgroundPipe } from '@koobiq/components/core'; +import { KbqFilter, KbqFilterBarModule, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar'; +import { kbqBuildUsernameText, KbqUserInfo, KbqUsernameModule, KbqUsernamePipe } from '@koobiq/components/username'; + +const USERS: KbqUserInfo[] = [ + { firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root', login: 'mroot', site: 'corp' }, + { firstName: 'Alice', middleName: 'Marie', lastName: 'Stone', login: 'astone' }, + { firstName: 'Robert', lastName: 'Green', login: 'rgreen', site: 'dev' }, + { firstName: 'Elena', middleName: 'Vera', lastName: 'Fox', login: 'efox' } +]; + +/** + * @title Username filter bar option + */ +@Component({ + selector: 'username-filter-bar-option-example', + imports: [KbqFilterBarModule, KbqUsernameModule, KbqHighlightBackgroundPipe], + template: ` + + @for (pipe of activeFilter.pipes; track pipe) { + + } + + + + @let searchText = pipe.searchControl.value; + + + + @let fullName = option.value | kbqUsername; + + + @if (option.value?.login) { + + } + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UsernameFilterBarOptionExample implements AfterViewInit { + private readonly usernamePipe = inject(KbqUsernamePipe); + private readonly userOptionTemplate = viewChild>('userOption'); + + activeFilter: KbqFilter = { + name: '', + readonly: false, + disabled: false, + changed: false, + saved: false, + pipes: [ + { + name: 'Assignee', + type: KbqPipeTypes.Select, + value: null, + search: true, + cleanable: true, + removable: false, + disabled: false + } + ] + }; + + pipeTemplates: KbqPipeTemplate[] = []; + + ngAfterViewInit(): void { + this.pipeTemplates = [ + { + name: 'Assignee', + type: KbqPipeTypes.Select, + values: USERS.map((user) => ({ + name: kbqBuildUsernameText( + { name: this.usernamePipe.transform(user), login: user.login, site: user.site }, + { formatSite: (s) => s } + ), + value: user, + id: user.login + })), + valueTemplate: this.userOptionTemplate(), + cleanable: true, + removable: false, + disabled: false + } + ]; + } +} diff --git a/packages/docs-examples/components/username/username-search/username-search-example.ts b/packages/docs-examples/components/username/username-search/username-search-example.ts new file mode 100644 index 0000000000..d30b58da95 --- /dev/null +++ b/packages/docs-examples/components/username/username-search/username-search-example.ts @@ -0,0 +1,94 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { KbqHighlightBackgroundPipe } from '@koobiq/components/core'; +import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqInputModule } from '@koobiq/components/input'; +import { kbqBuildUsernameText, KbqUserInfo, KbqUsernameModule, KbqUsernamePipe } from '@koobiq/components/username'; +import { startWith } from 'rxjs'; + +/** + * @title Username search + */ +@Component({ + selector: 'username-search-example', + imports: [ + ReactiveFormsModule, + KbqFormFieldModule, + KbqInputModule, + KbqUsernameModule, + KbqIconModule, + KbqHighlightBackgroundPipe + ], + template: ` + + + + + + +
+ @for (user of filteredUsers(); track user) { + + + @let fullName = user | kbqUsername; + + + @if (user.login) { + + } + + + } @empty { + Nothing found + } +
+ `, + styles: ` + .example__users-list { + display: flex; + flex-direction: column; + gap: var(--kbq-size-s); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-column layout-gap-m layout-padding-m' + } +}) +export class UsernameSearchExample { + private readonly usernamePipe = inject(KbqUsernamePipe); + + protected readonly searchControl = new FormControl('', { nonNullable: true }); + + private readonly searchText = toSignal(this.searchControl.valueChanges.pipe(startWith('')), { initialValue: '' }); + + protected readonly users: KbqUserInfo[] = [ + { firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root', login: 'mroot', site: 'corp' }, + { firstName: 'Alice', middleName: 'Marie', lastName: 'Stone', login: 'astone' }, + { firstName: 'Robert', lastName: 'Green', login: 'rgreen', site: 'dev' }, + { firstName: 'Elena', middleName: 'Vera', lastName: 'Fox', login: 'efox' } + ]; + + protected readonly filteredUsers = computed(() => { + const query = (this.searchText() ?? '').toLowerCase().trim(); + + if (!query) return this.users; + + return this.users.filter((user) => + kbqBuildUsernameText( + { name: this.usernamePipe.transform(user), login: user.login, site: user.site }, + { formatSite: (s) => s } + ) + .toLowerCase() + .includes(query) + ); + }); +} diff --git a/tools/public_api_guard/components/username.api.md b/tools/public_api_guard/components/username.api.md index 3b4f48174e..a01e755026 100644 --- a/tools/public_api_guard/components/username.api.md +++ b/tools/public_api_guard/components/username.api.md @@ -11,6 +11,11 @@ import { PipeTransform } from '@angular/core'; // @public export const KBQ_PROFILE_MAPPING: InjectionToken; +// @public +export function kbqBuildUsernameText(data: { + name: string; +} & Partial>, options?: KbqUsernameTextOptions): string; + // @public export const kbqDefaultFullNameFormat = "lf.m."; @@ -137,6 +142,12 @@ export class KbqUsernameSecondaryHint { // @public export type KbqUsernameStyle = 'default' | 'error' | 'accented' | 'inherit'; +// @public (undocumented) +export interface KbqUsernameTextOptions { + formatLogin?: (login: string) => string; + formatSite?: (site: string) => string; +} + // (No @packageDocumentation comment for this package) ```