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
2 changes: 2 additions & 0 deletions packages/components-dev/username/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { UsernameExamplesModule } from '../../docs-examples/components/username'
<username-playground-example />
<username-custom-example />
<username-as-link-example />
<username-search-example />
<username-filter-bar-option-example />
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"`;
16 changes: 16 additions & 0 deletions packages/components/username/username.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- example(username-as-link) -->

### Search and highlight

To filter a list of users by the displayed name, inject `KbqUsernamePipe` as a service and call its `transform` method — it returns the same string the component renders by default.

The matched fragment is easy to highlight in a custom template with the `kbqHighlightBackground` pipe.

<!-- example(username-search) -->

#### Search in Filter bar

The same approach works in the filter bar, inside `kbq-pipe-select`. The look of an option is defined by `valueTemplate`, so you can render the username component as its label. The entered search text comes from the template context: the `$implicit` variable holds the pipe component itself, and its `searchControl.value` property keeps the current string.

Put the formatted name in the `name` property — it is used as the trigger display value. Add the login and site to `searchKey` so the built-in filter covers every visible field.

<!-- example(username-filter-bar-option) -->
29 changes: 29 additions & 0 deletions packages/components/username/username.pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
KbqMappingMissingError
} from './constants';
import { KbqFormatKeyToProfileMapping, KbqFormatKeyToProfileMappingExtended, KbqUsernameFormatKey } from './types';
import { KbqUserInfo } from './username';

@Injectable({ providedIn: 'root' })
@Pipe({
Expand Down Expand Up @@ -95,3 +96,31 @@ export class KbqUsernameCustomPipe<T = unknown> 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<Pick<KbqUserInfo, 'login' | 'site'>>,
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(' ');
}
16 changes: 16 additions & 0 deletions packages/components/username/username.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,19 @@
Компонент удобно использовать внутри ссылок. Чтобы он визуально соответствовал стилю ссылки, установите стиль `inherit` — в этом случае цвет и оформление будут унаследованы от родительского элемента.

<!-- example(username-as-link) -->

### Поиск и подсветка

Чтобы фильтровать список пользователей по отображаемому имени, внедрите `KbqUsernamePipe` как сервис и вызовите его метод `transform` — он вернет ту же строку, которую компонент отображает по умолчанию.

Найденный фрагмент подсветите в пользовательском шаблоне с помощью пайпа `kbqHighlightBackground`.

<!-- example(username-search) -->

#### Поиск в Filter bar

Тот же подход работает в панели фильтров, внутри `kbq-pipe-select`. Внешний вид опции определяет `valueTemplate` — в нем можно вывести компонент имени пользователя как подпись опции. Текст поиска доступен из контекста шаблона: переменная `$implicit` содержит компонент пайпа, а его свойство `searchControl.value` хранит текущую строку.

Отображаемое имя укажите в свойстве `name` — оно используется как значение в триггере. В `searchKey` добавьте логин и сайт, чтобы встроенный фильтр охватывал все видимые поля.

<!-- example(username-filter-bar-option) -->
34 changes: 33 additions & 1 deletion packages/components/username/username.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(component: Type<T>, providers: any[] = []): ComponentFixture<T> => {
TestBed.configureTestingModule({ imports: [component], providers }).compileComponents();
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 13 additions & 2 deletions packages/docs-examples/components/username/index.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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: `
<kbq-filter-bar [pipeTemplates]="pipeTemplates" [(filter)]="activeFilter">
@for (pipe of activeFilter.pipes; track pipe) {
<ng-container *kbqPipe="pipe" />
}
</kbq-filter-bar>

<ng-template #userOption let-pipe let-option="option">
@let searchText = pipe.searchControl.value;

<kbq-username>
<kbq-username-custom-view>
@let fullName = option.value | kbqUsername;
<span kbqUsernamePrimary [innerHTML]="fullName | kbqHighlightBackground: searchText"></span>

@if (option.value?.login) {
<span
kbqUsernameSecondary
[innerHTML]="option.value.login | kbqHighlightBackground: searchText"
></span>
}
</kbq-username-custom-view>
</kbq-username>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UsernameFilterBarOptionExample implements AfterViewInit {
private readonly usernamePipe = inject(KbqUsernamePipe);
private readonly userOptionTemplate = viewChild<TemplateRef<any>>('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
}
];
}
}
Original file line number Diff line number Diff line change
@@ -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: `
<kbq-form-field>
<i kbqPrefix kbq-icon="kbq-magnifying-glass_16"></i>
<input kbqInput type="text" placeholder="Search" autocomplete="off" [formControl]="searchControl" />
<kbq-cleaner />
</kbq-form-field>

<div class="example__users-list">
@for (user of filteredUsers(); track user) {
<kbq-username>
<kbq-username-custom-view>
@let fullName = user | kbqUsername;
<span
kbqUsernamePrimary
[innerHTML]="fullName | kbqHighlightBackground: searchControl.value.trim()"
></span>

@if (user.login) {
<span
kbqUsernameSecondary
[innerHTML]="user.login | kbqHighlightBackground: searchControl.value.trim()"
></span>
}
</kbq-username-custom-view>
</kbq-username>
} @empty {
<span class="kbq-text-normal kbq-second">Nothing found</span>
}
</div>
`,
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)
);
});
}
11 changes: 11 additions & 0 deletions tools/public_api_guard/components/username.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { PipeTransform } from '@angular/core';
// @public
export const KBQ_PROFILE_MAPPING: InjectionToken<KbqFormatKeyToProfileMapping | KbqFormatKeyToProfileMappingExtended>;

// @public
export function kbqBuildUsernameText(data: {
name: string;
} & Partial<Pick<KbqUserInfo, 'login' | 'site'>>, options?: KbqUsernameTextOptions): string;

// @public
export const kbqDefaultFullNameFormat = "lf.m.";

Expand Down Expand Up @@ -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)

```