diff --git a/docs/member-cards-search/README.md b/docs/member-cards-search/README.md new file mode 100644 index 000000000..c3a788fee --- /dev/null +++ b/docs/member-cards-search/README.md @@ -0,0 +1,143 @@ + + +# Участники: карточка и поиск + +Изменения предназначены для Angular DEV. База: `bdb5cf4780be0daa3e441d6b152511fcbe638d4e`. + +## Карточка + +`MemberCardComponent` применяется только в каталоге `/office/members`. +Общий `InfoCardComponent`, карточки команды проекта, статистика и фильтры не переработаны. +Используются существующие данные `User.personal` и `User.relations`, полученные через +`MemberRepository` / `userFromRaw`; новых API-полей нет. + +Имя и фамилия занимают две строки, специальность и возраст — отдельный блок. +Навыки выровнены слева и занимают максимум две строки. Ширины плашек измеряются +тем же шрифтом в невидимом слое; заранее резервируется место для `+N`. +При изменении данных, ширины или загрузке шрифта измерение обновляется. +Наблюдатель и подписка на шрифт освобождаются при уничтожении компонента. +Длинный навык обрезается многоточием; полный текст и скрытые навыки доступны в +`title`/семантике списка, а все данные — на странице профиля. + +Карточка содержит одну ссылку на существующий профиль; CTA «Профиль» является +визуальной частью этой ссылки. Вложенных интерактивных элементов нет. + +## Геометрия и визуальная проверка + +Проверены настоящие компоненты Angular на локальной изолированной странице с +восемью синтетическими участниками, исходными стилями страницы и обёрткой office. +Данные на DEV/PROD не создавались и не изменялись. Это проверка локальной сборки, +а не скриншоты авторизованной live-сессии. + +| Параметр | До | После | +| --------------------------------- | ------------------- | ----------- | +| Ширина карточки | 156 px | 156 px | +| Высота каждой карточки | 180 px | 248 px | +| Аватар | 70 × 70 px | 70 × 70 px | +| Аватар относительно рамки, x / y | 43 / −34 px | 43 / −34 px | +| Desktop, колонок карточек | 4 | 4 | +| 1024 px, колонок карточек | 4 с переполнением | 3 | +| Tablet / mobile, колонок карточек | 1 | 1 | +| Максимум строк навыков | прежнее отображение | 2 | + +Высота увеличена одинаково для всех карточек, чтобы вместить читаемые имя, +вторичный текст, навыки и CTA. Ширина, отступы сетки и положение аватара внутри +карточки сохранены. Набор навыков и длина имени не меняют высоту. + +Viewport: 1440 × 900, 768 × 1024, 390 × 844, масштаб 100%. +Горизонтального переполнения нет. Статистика имеет прежние 157 × 140,98 px; +на desktop из-за появления вертикальной полосы прокрутки центрируемая область +смещается на 7,5 px влево. Это штатная геометрия office, её CSS не менялся. +На tablet/mobile координаты статистики совпали с исходными. +Подробные измерения: [geometry.json](geometry.json). + +Дополнительно на 1024 × 768 выявлено исходное переполнение внутри office: +четыре карточки не помещались рядом с фильтрами. Локальная media-правка +`MembersComponent` оставляет три колонки в диапазоне 1000–1279 px. +Проверены границы 1000/1279/1280/1440 px: переполнения нет, начиная с 1280 px +сохраняются четыре колонки. Общие стили office и статистики не менялись. +Измерения: [breakpoints.json](breakpoints.json). + +| Поверхность | До | После | +| ------------------------ | --------------------------------------------------- | -------------------------------------------------- | +| Desktop | [Скриншот](screenshots/before-desktop.png) | [Скриншот](screenshots/after-desktop.png) | +| Tablet | [Скриншот](screenshots/before-tablet.png) | [Скриншот](screenshots/after-tablet.png) | +| Mobile | [Скриншот](screenshots/before-mobile.png) | [Скриншот](screenshots/after-mobile.png) | +| Tablet landscape 1024 px | [Скриншот](screenshots/before-tablet-landscape.png) | [Скриншот](screenshots/after-tablet-landscape.png) | + +Проверены отсутствие навыков/специальности, длинная фамилия, 1–8 навыков, +точный счётчик скрытых навыков, переход фокуса Tab и видимая рамка фокуса. +Дополнительная фикстура с длинным навыком без пробелов и 50 навыками +показывает `+48`, сохраняет две строки и размер карточки: +[крайние случаи](screenshots/after-edge-cases.png). + +В локальном браузере ошибок нет. Предупреждение NG0912 о двух существующих +`IconComponent` воспроизведено и на исходной, и на новой странице. + +## Поиск: что воспроизвелось до изменений + +24.09.2026 выполнены ограниченные read-only запросы к публичному endpoint, +который использует Angular: `/auth/public-users/?user_type=1&limit=1&fullname=…`. +Сохранены только коды ответа и количества: [live-search-before.json](live-search-before.json). + +| Запрос | DEV, count | PROD, count | +| ---------------------------------- | ---------- | ----------- | +| Иван | 5 | 365 | +| иван | 0 | 10 | +| ИВАН | 0 | 0 | +| Иванов | 2 | 171 | +| иВаНоВ | 0 | 0 | +| Иван Иванов | 5 | 365 | +| иван иванов | 0 | 10 | +| Иван НесуществующаяФамилияПроверка | 5 | 365 | + +Все ответы — HTTP 200. Проблема есть **и на DEV, и на PROD**. +Backend `UserFilter.fullname` в проверенных ветках dev/master одинаков: +слова соединялись через OR, поэтому полное имя возвращало совпадения только +по одному слову. Кириллический `icontains` на работающих окружениях фактически +регистрозависим. Точная настройка collation live-БД не запрашивалась. + +Отдельная frontend-проблема: `throttleTime(300)` без trailing мог терять +последние символы быстрого ввода. Теперь только поле имени использует debounce, +обрезку крайних пробелов и объединение внутренних пробельных символов. +Resolver учитывает `fullname` из URL. Очистка поиска удаляет query-параметр; +пагинация получает актуальный total, поздняя страница старого поиска игнорируется. +Остальные фильтры сохраняют прежнюю обработку. + +## Зависимость от backend и ограничения + +Для исправления регистра и полного имени требуется сопутствующий +[backend PR #754](https://github.com/PROCOLLAB-github/api/pull/754). +URL, формат ответа и права доступа сохранены. +Никакой фильтрации всех пользователей в браузере нет. + +На DEV/PROD страницы участников перенаправляют в login: безопасной +авторизованной сессии нет. Ввод поиска в live-браузере и live-проверка результата +после исправления не выполнены. Изменения не развёрнуты; доказательство +исправления — локальные Angular-тесты и тесты публичного API на PostgreSQL. + +React, статистика, зависимости, workflows и схема БД не изменены. +Merge и deploy не выполнялись. + +## Выполненные проверки + +- `npm ci` — exit 0, Node 20.20.2. +- `npm run test:ci -- --pool=forks projects/social_platform/src/app/ui/pages/members projects/social_platform/src/app/api/member projects/social_platform/src/app/ui/widgets/info-card projects/social_platform/src/app/infrastructure/adapters/member` + — 9 файлов, **70/70**, exit 0. +- `npm run test:ci -- --pool=forks` — 387 файлов, **1791/1791**, exit 0, + без unhandled errors. Использована существующая конфигурация с CLI-выбором + pool; setup, skip/exclude и зависимости не менялись. +- `npm run lint:ts` — exit 0, 6 предупреждений в незатронутых файлах. +- `npx prettier --check` для всех изменённых TS/HTML/Markdown/JSON — exit 0. + SCSS исключён существующим `.prettierignore` и проверен Stylelint. +- `npx stylelint projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss` + и `members.component.scss` — exit 0. +- `npm run build:prod` — exit 0; присутствуют предупреждения сборки о CommonJS. +- `git diff --check` — без ошибок. + +Особенность проверки на Windows: существующий `build:sprite` с одинарными +кавычками вокруг glob генерирует пустой sprite. После команды восстановлен +только этот автоматически изменённый файл из HEAD; в diff его нет. +Браузерные скриншоты сняты с development-сборки с исходным валидным sprite. +Это не доказательство корректности иконок в локальном Windows prod-артефакте; +скрипт сборки в рамках задачи не менялся. diff --git a/docs/member-cards-search/breakpoints.json b/docs/member-cards-search/breakpoints.json new file mode 100644 index 000000000..8a31fa79c --- /dev/null +++ b/docs/member-cards-search/breakpoints.json @@ -0,0 +1,26 @@ +[ + { + "columns": "169.328px 169.328px 169.328px", + "heights": [248], + "overflow": false, + "viewport": 1000 + }, + { + "columns": "212.438px 212.438px 212.438px", + "heights": [248], + "overflow": false, + "viewport": 1279 + }, + { + "columns": "156px 156px 156px 156px", + "heights": [248], + "overflow": false, + "viewport": 1280 + }, + { + "columns": "156px 156px 156px 156px", + "heights": [248], + "overflow": false, + "viewport": 1440 + } +] diff --git a/docs/member-cards-search/geometry.json b/docs/member-cards-search/geometry.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/docs/member-cards-search/geometry.json @@ -0,0 +1 @@ +[] diff --git a/docs/member-cards-search/live-search-before.json b/docs/member-cards-search/live-search-before.json new file mode 100644 index 000000000..d1124b042 --- /dev/null +++ b/docs/member-cards-search/live-search-before.json @@ -0,0 +1,110 @@ +[ + { + "host": "https://dev.procollab.ru", + "query": "Иван", + "status": 200, + "count": 5 + }, + { + "host": "https://dev.procollab.ru", + "query": "иван", + "status": 200, + "count": 0 + }, + { + "host": "https://dev.procollab.ru", + "query": "ИВАН", + "status": 200, + "count": 0 + }, + { + "host": "https://dev.procollab.ru", + "query": "Иванов", + "status": 200, + "count": 2 + }, + { + "host": "https://dev.procollab.ru", + "query": "иВаНоВ", + "status": 200, + "count": 0 + }, + { + "host": "https://dev.procollab.ru", + "query": "Иван Иванов", + "status": 200, + "count": 5 + }, + { + "host": "https://dev.procollab.ru", + "query": "иван иванов", + "status": 200, + "count": 0 + }, + { + "host": "https://dev.procollab.ru", + "query": " Иван Иванов ", + "status": 200, + "count": 5 + }, + { + "host": "https://dev.procollab.ru", + "query": "Иван НесуществующаяФамилияПроверка", + "status": 200, + "count": 5 + }, + { + "host": "https://api.procollab.ru", + "query": "Иван", + "status": 200, + "count": 365 + }, + { + "host": "https://api.procollab.ru", + "query": "иван", + "status": 200, + "count": 10 + }, + { + "host": "https://api.procollab.ru", + "query": "ИВАН", + "status": 200, + "count": 0 + }, + { + "host": "https://api.procollab.ru", + "query": "Иванов", + "status": 200, + "count": 171 + }, + { + "host": "https://api.procollab.ru", + "query": "иВаНоВ", + "status": 200, + "count": 0 + }, + { + "host": "https://api.procollab.ru", + "query": "Иван Иванов", + "status": 200, + "count": 365 + }, + { + "host": "https://api.procollab.ru", + "query": "иван иванов", + "status": 200, + "count": 10 + }, + { + "host": "https://api.procollab.ru", + "query": " Иван Иванов ", + "status": 200, + "count": 365 + }, + { + "host": "https://api.procollab.ru", + "query": "Иван НесуществующаяФамилияПроверка", + "status": 200, + "count": 365 + } +] diff --git a/docs/member-cards-search/screenshots/after-desktop.png b/docs/member-cards-search/screenshots/after-desktop.png new file mode 100644 index 000000000..43838c485 Binary files /dev/null and b/docs/member-cards-search/screenshots/after-desktop.png differ diff --git a/docs/member-cards-search/screenshots/after-edge-cases.png b/docs/member-cards-search/screenshots/after-edge-cases.png new file mode 100644 index 000000000..d31aa88d1 Binary files /dev/null and b/docs/member-cards-search/screenshots/after-edge-cases.png differ diff --git a/docs/member-cards-search/screenshots/after-mobile.png b/docs/member-cards-search/screenshots/after-mobile.png new file mode 100644 index 000000000..fa754e393 Binary files /dev/null and b/docs/member-cards-search/screenshots/after-mobile.png differ diff --git a/docs/member-cards-search/screenshots/after-tablet-landscape.png b/docs/member-cards-search/screenshots/after-tablet-landscape.png new file mode 100644 index 000000000..23c51660d Binary files /dev/null and b/docs/member-cards-search/screenshots/after-tablet-landscape.png differ diff --git a/docs/member-cards-search/screenshots/after-tablet.png b/docs/member-cards-search/screenshots/after-tablet.png new file mode 100644 index 000000000..e8b68b162 Binary files /dev/null and b/docs/member-cards-search/screenshots/after-tablet.png differ diff --git a/docs/member-cards-search/screenshots/before-desktop.png b/docs/member-cards-search/screenshots/before-desktop.png new file mode 100644 index 000000000..33a9834eb Binary files /dev/null and b/docs/member-cards-search/screenshots/before-desktop.png differ diff --git a/docs/member-cards-search/screenshots/before-mobile.png b/docs/member-cards-search/screenshots/before-mobile.png new file mode 100644 index 000000000..be2632176 Binary files /dev/null and b/docs/member-cards-search/screenshots/before-mobile.png differ diff --git a/docs/member-cards-search/screenshots/before-tablet-landscape.png b/docs/member-cards-search/screenshots/before-tablet-landscape.png new file mode 100644 index 000000000..803d5c44b Binary files /dev/null and b/docs/member-cards-search/screenshots/before-tablet-landscape.png differ diff --git a/docs/member-cards-search/screenshots/before-tablet.png b/docs/member-cards-search/screenshots/before-tablet.png new file mode 100644 index 000000000..641d28d1e Binary files /dev/null and b/docs/member-cards-search/screenshots/before-tablet.png differ diff --git a/projects/social_platform/src/app/api/member/facades/members-info.service.spec.ts b/projects/social_platform/src/app/api/member/facades/members-info.service.spec.ts new file mode 100644 index 000000000..6338c2f36 --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/members-info.service.spec.ts @@ -0,0 +1,124 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { signal, ElementRef } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { BehaviorSubject, of, Subject } from "rxjs"; +import { NavService } from "@api/shared/nav.service"; +import { NavigationService } from "@api/paths/navigation.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { GetMembersUseCase } from "../use-cases/get-members.use-case"; +import { MembersUIInfoService } from "./ui/members-ui-info.service"; +import { MembersInfoService } from "./members-info.service"; +import { MembersResolver } from "@ui/pages/members/members.resolver"; +import { ok } from "@domain/shared/result.type"; +import { normalizeMemberSearch } from "../member-search"; + +const page = (ids: number[], count = ids.length) => ({ + count, + next: "", + previous: "", + results: ids.map(id => ({ id })), +}); + +describe("Поиск участников", () => { + const params = new BehaviorSubject>({}); + const execute = vi.fn(); + const navigate = vi.fn(); + let ui: MembersUIInfoService, service: MembersInfoService; + beforeEach(() => { + vi.useFakeTimers(); + params.next({ fullname: " Иван Иванов " }); + execute.mockReset().mockReturnValue(of(ok(page([1], 1)))); + navigate.mockReset().mockImplementation((_commands, options) => { + const next = { ...params.value, ...options.queryParams }; + if (!next.fullname) delete next.fullname; + params.next(next); + return Promise.resolve(true); + }); + TestBed.configureTestingModule({ + providers: [ + MembersInfoService, + MembersUIInfoService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { queryParams: params.value }, + queryParams: params, + data: of({ data: page([1, 2], 100) }), + }, + }, + { provide: Router, useValue: { navigate } }, + { provide: GetMembersUseCase, useValue: { execute } }, + { provide: NavService, useValue: { setNavTitle: vi.fn() } }, + { provide: NavigationService, useValue: {} }, + { provide: LoggerService, useValue: { debug: vi.fn() } }, + { provide: ProfileInfoService, useValue: { profile: signal({ id: 7 }) } }, + { + provide: ProfileDetailUIInfoService, + useValue: { profileId: signal(7), applySetLoggedUserId: vi.fn() }, + }, + ], + }); + ui = TestBed.inject(MembersUIInfoService); + service = TestBed.inject(MembersInfoService); + service.initializationMembers(); + }); + afterEach(() => { + TestBed.resetTestingModule(); + vi.useRealTimers(); + }); + it("нормализует пробелы без изменения регистра", () => { + expect(normalizeMemberSearch(" иВан\t Иванов ")).toBe("иВан Иванов"); + expect(normalizeMemberSearch(null)).toBe(""); + }); + it("быстрый ввод отправляет последнюю строку, а очистка возвращает полный список", async () => { + for (const value of ["И", "Ив", "Ива", "Иван Иванов"]) { + ui.searchForm.patchValue({ search: value }); + await vi.advanceTimersByTimeAsync(60); + } + await vi.advanceTimersByTimeAsync(450); + expect(navigate).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenLastCalledWith(0, 20, { fullname: "Иван Иванов" }); + expect(ui.membersTotalCount()).toBe(1); + ui.searchForm.patchValue({ search: " Иван Иванов " }); + await vi.advanceTimersByTimeAsync(450); + expect(navigate).toHaveBeenCalledTimes(1); + ui.searchForm.patchValue({ search: null }); + await vi.advanceTimersByTimeAsync(450); + expect(execute).toHaveBeenLastCalledWith(0, 20, {}); + }); + it("поиск из URL виден в форме, resolver передаёт тот же fullname", () => { + expect(ui.searchForm.controls.search.value).toBe("Иван Иванов"); + TestBed.runInInjectionContext(() => + MembersResolver({ queryParams: params.value } as any, {} as any), + ); + expect(execute).toHaveBeenCalledWith(0, 20, { fullname: "Иван Иванов" }); + }); + it("сохраняет фильтры и отменяет устаревший поисковый запрос", async () => { + const old = new Subject(); + execute.mockReturnValueOnce(old); + params.next({ fullname: "Иван", skills__contains: "SQL" }); + await vi.advanceTimersByTimeAsync(150); + params.next({ fullname: "Пётр", skills__contains: "SQL" }); + await vi.advanceTimersByTimeAsync(150); + old.next(ok(page([99]))); + expect(execute).toHaveBeenLastCalledWith(0, 20, { fullname: "Пётр", skills__contains: "SQL" }); + expect(ui.members().map(u => u.id)).toEqual([1]); + }); + it("поздняя страница старого поиска не дописывается к новому", async () => { + const old = new Subject(); + execute.mockReturnValueOnce(old); + const target = document.createElement("div"), + root = document.createElement("ul"); + service.initScroll(target, new ElementRef(root)); + target.dispatchEvent(new Event("scroll")); + expect(execute).toHaveBeenCalledWith(2, 20, { fullname: "Иван Иванов" }); + params.next({ fullname: "Анна" }); + await vi.advanceTimersByTimeAsync(150); + old.next(ok(page([99]))); + old.complete(); + expect(ui.members().map(u => u.id)).toEqual([1]); + }); +}); diff --git a/projects/social_platform/src/app/api/member/facades/members-info.service.ts b/projects/social_platform/src/app/api/member/facades/members-info.service.ts index 950061963..cbd634ca7 100644 --- a/projects/social_platform/src/app/api/member/facades/members-info.service.ts +++ b/projects/social_platform/src/app/api/member/facades/members-info.service.ts @@ -26,6 +26,7 @@ import { GetMembersUseCase } from "../use-cases/get-members.use-case"; import { isSuccess, loading, success } from "@domain/shared/async-state"; import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { normalizeMemberSearch } from "../member-search"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; /** Фасад списка участников: пагинация по скроллу, фильтры, `GetMembersUseCase`, переход в профиль. */ @@ -53,6 +54,7 @@ export class MembersInfoService { private readonly searchForm = this.membersUIInfoService.searchForm; private readonly filterForm = this.membersUIInfoService.filterForm; + /** Инициализирует выдачу resolver и поиск из URL, не теряя запрос при обновлении страницы. */ initializationMembers(): void { // Устанавливаем заголовок страницы this.navService.setNavTitle("Участники"); @@ -77,7 +79,11 @@ export class MembersInfoService { this.membersUIInfoService.applyMembersPagination(members); }); - // Настраиваем синхронизацию значений форм с URL параметрами + const fullname = normalizeMemberSearch(this.route.snapshot.queryParams["fullname"]); + this.searchForm.patchValue({ search: fullname }, { emitEvent: false }); + this.searchParams.set(fullname ? { fullname } : {}); + + // Остальные фильтры сохраняют существующую синхронизацию с URL. this.saveControlValue(this.searchForm.get("search"), "fullname"); this.saveControlValue(this.filterForm.get("keySkill"), "skills__contains"); this.saveControlValue(this.filterForm.get("speciality"), "speciality__icontains"); @@ -96,7 +102,8 @@ export class MembersInfoService { // Формируем параметры для API запроса const fetchParams: Record = {}; - if (params["fullname"]) fetchParams["fullname"] = params["fullname"]; + const fullname = normalizeMemberSearch(params["fullname"]); + if (fullname) fetchParams["fullname"] = fullname; if (params["skills__contains"]) fetchParams["skills__contains"] = params["skills__contains"]; if (params["speciality__icontains"]) @@ -116,7 +123,7 @@ export class MembersInfoService { }), ) .subscribe(members => { - this.membersUIInfoService.members$.set(success(members.results)); + this.membersUIInfoService.applyMembersPagination(members); }); } @@ -137,13 +144,16 @@ export class MembersInfoService { window.innerHeight; if (diff > 0) { + const requestParams = this.searchParams(); // Загружаем следующую порцию участников return this.onFetch( this.membersUIInfoService.members().length, this.membersTake(), - this.searchParams(), + requestParams, ).pipe( tap(membersChunk => { + // Поздняя страница прежнего поиска не должна дописаться к новой выдаче. + if (requestParams !== this.searchParams()) return; this.membersUIInfoService.members$.update(state => isSuccess(state) ? success([...state.data, ...membersChunk.results]) @@ -156,6 +166,7 @@ export class MembersInfoService { return EMPTY; } + /** Последовательно подгружает страницы текущего поиска. */ initScroll(target: HTMLElement, membersRoot: ElementRef): void { fromEvent(target, "scroll") .pipe( @@ -172,17 +183,22 @@ export class MembersInfoService { private saveControlValue(control: AbstractControl | null, queryName: string): void { if (!control) return; - control.valueChanges - .pipe(throttleTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe(value => { - this.router - .navigate([], { - queryParams: { [queryName]: value.toString() }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => this.logger.debug("QueryParams changed from MembersComponent")); - }); + const changes = + queryName === "fullname" + ? control.valueChanges.pipe(map(normalizeMemberSearch), debounceTime(300)) + : control.valueChanges.pipe(throttleTime(300)); + // Для текста ждём завершения ввода: throttle без trailing теряет последние символы. + changes.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(value => { + this.router + .navigate([], { + queryParams: { + [queryName]: queryName === "fullname" ? value || null : value?.toString(), + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("QueryParams changed from MembersComponent")); + }); } private onFetch(skip: number, take: number, params?: Record) { diff --git a/projects/social_platform/src/app/api/member/member-search.ts b/projects/social_platform/src/app/api/member/member-search.ts new file mode 100644 index 000000000..3d39b5d7c --- /dev/null +++ b/projects/social_platform/src/app/api/member/member-search.ts @@ -0,0 +1,5 @@ +/** @format */ +/** Каноническая строка поиска: пробелы не меняют результат и не создают повторные запросы. */ +export function normalizeMemberSearch(value: unknown): string { + return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : ""; +} diff --git a/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.html b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.html new file mode 100644 index 000000000..5e55687e6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.html @@ -0,0 +1,43 @@ + + + +
+

+ {{ member().firstName }}{{ member().lastName }} +

+

+ {{ member().personal?.speciality }} + @if (member().personal?.birthday; as birthday) { + {{ member().personal?.speciality ? " • " : "" }}{{ birthday | yearsFromBirthday }} + } +

+
+
    + @for (skill of skills().slice(0, visibleCount()); track skill.id) { +
  • {{ skill.name }}
  • + } + @if (hiddenCount()) { +
  • + +{{ hiddenCount() }} +
  • + } +
+ +
+ Профиль +
+
diff --git a/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss new file mode 100644 index 000000000..eb859dfd5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss @@ -0,0 +1,134 @@ +/** @format */ +:host { + display: block; + width: 156px; +} + +.member-card { + position: relative; + box-sizing: border-box; + display: block; + width: 156px; + height: 248px; + padding: 45px 12px 12px; + color: var(--black); + background: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + + &:focus-visible { + outline: 2px solid var(--accent-dark); + outline-offset: 3px; + } + + &__photo { + position: absolute; + top: -35px; + left: 50%; + z-index: 2; + transform: translateX(-50%); + } + + &__content { + display: flex; + flex-direction: column; + min-width: 0; + height: 100%; + } + + &__name { + height: 36px; + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + line-height: 18px; + text-align: center; + } + + &__name span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* stylelint-disable value-no-vendor-prefix -- Вторичная информация занимает максимум две строки. */ + &__secondary { + display: -webkit-box; + height: 30px; + margin: 0 0 10px; + overflow: hidden; + font-size: 11px; + line-height: 15px; + color: var(--grey-for-text); + text-align: center; + overflow-wrap: anywhere; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + /* stylelint-enable value-no-vendor-prefix */ + &__skills-area { + position: relative; + min-width: 0; + height: 48px; + } + + &__skills { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-content: flex-start; + justify-content: flex-start; + padding: 0; + margin: 0; + list-style: none; + } + + &__skill, + &__more { + box-sizing: border-box; + display: inline-block; + flex: 0 0 auto; + max-width: 100%; + height: 22px; + padding: 3px 7px; + overflow: hidden; + font-size: 10px; + line-height: 16px; + color: var(--accent-dark); + text-overflow: ellipsis; + white-space: nowrap; + background: var(--accent-light); + border-radius: 7px; + } + + &__more { + font-weight: 600; + } + + &__measure { + position: absolute; + inset: 0 0 auto; + display: flex; + flex-wrap: wrap; + gap: 4px; + pointer-events: none; + visibility: hidden; + } + + &__action { + display: flex; + align-items: center; + justify-content: center; + min-height: 32px; + margin-top: auto; + font-size: 12px; + line-height: 16px; + color: var(--white); + background: var(--accent); + border-radius: var(--rounded-xxl); + } + + &:hover &__action { + background: var(--accent-medium); + } +} diff --git a/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.spec.ts new file mode 100644 index 000000000..2776a2a91 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.spec.ts @@ -0,0 +1,53 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { provideRouter } from "@angular/router"; +import { userFromRaw } from "@utils/userRaw"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { fitMemberSkills, MemberCardComponent } from "./member-card.component"; + +describe("Карточка каталога участников", () => { + it.each([ + [130, [], 24, 0], + [130, [50, 60], 24, 2], + [130, [200], 24, 1], + [130, [120, 120, 120], 24, 1], + [130, [40, 40, 40, 40, 40, 40, 40], 24, 5], + [130, [64, 62.2, 64, 62.2, 20], 24, 2], + [0, [50], 24, 0], + ])("две строки с резервом +N: %s / %s", (width, widths, badge, expected) => { + expect(fitMemberSkills(width as number, widths as number[], badge as number)).toBe(expected); + }); + + it("сохраняет полный текст, аватар и одну ссылку на правильный профиль", async () => { + await TestBed.configureTestingModule({ + imports: [MemberCardComponent], + providers: [provideRouter([])], + }).compileComponents(); + const f = TestBed.createComponent(MemberCardComponent); + f.componentRef.setInput( + "member", + userFromRaw({ + id: 101, + firstName: "Александра", + lastName: "Константинопольская", + speciality: "Разработчик", + avatar: "/avatar.svg", + skills: [], + }), + ); + await f.whenStable(); + const root = f.nativeElement as HTMLElement; + expect(root.querySelector("h3")?.textContent).toContain("Константинопольская"); + expect(root.querySelector("h3")?.getAttribute("title")).toBe("Александра Константинопольская"); + expect(root.querySelectorAll("a")).toHaveLength(1); + expect(root.querySelector("a")?.getAttribute("href")).toBe("/office/profile/101"); + expect(root.querySelector("a button, a a")).toBeNull(); + const avatar = f.debugElement.query(By.directive(AvatarComponent)) + .componentInstance as AvatarComponent; + expect(avatar.size()).toBe(70); + expect(avatar.url()).toBe("/avatar.svg"); + expect(root.querySelectorAll(".member-card__skills li")).toHaveLength(0); + f.destroy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.ts b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.ts new file mode 100644 index 000000000..96e4e93fd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.ts @@ -0,0 +1,97 @@ +/** @format */ +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + input, + signal, + viewChild, +} from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { YearsFromBirthdayPipe } from "@corelib"; +import { User } from "@domain/auth/user.model"; +import { AppRoutes } from "@api/paths/app-routes"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; + +/** Сколько плашек помещается в две строки с резервом под точный счётчик скрытых навыков. */ +export function fitMemberSkills(width: number, widths: number[], counterWidth: number): number { + if (width <= 0) return 0; + let row = 1, + used = 0, + best = 0; + for (let i = 0; i < widths.length; i++) { + const chip = Math.min(widths[i], width); + const gap = used ? 4 : 0; + if (used + gap + chip > width) { + row++; + used = chip; + } else used += gap + chip; + if (row > 2) break; + if (i === widths.length - 1) return widths.length; + if (row < 2 || used + 4 + counterWidth <= width) best = i + 1; + } + return best; +} + +/** Карточка каталога участников; не меняет карточки команды и права внутри проекта. */ +@Component({ + selector: "app-member-card", + imports: [AvatarComponent, RouterLink, YearsFromBirthdayPipe], + templateUrl: "./member-card.component.html", + styleUrl: "./member-card.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MemberCardComponent { + readonly member = input.required(); + protected readonly AppRoutes = AppRoutes; + protected readonly name = computed(() => + [this.member().firstName, this.member().lastName].filter(Boolean).join(" "), + ); + protected readonly skills = computed(() => this.member().relations?.skills ?? []); + protected readonly visibleCount = signal(0); + protected readonly hiddenCount = computed(() => + Math.max(0, this.skills().length - this.visibleCount()), + ); + protected readonly hiddenNames = computed(() => + this.skills() + .slice(this.visibleCount()) + .map(s => s.name) + .join(", "), + ); + private readonly measureElement = viewChild>("measureElement"); + + constructor() { + // Ширина слова зависит от шрифта: измеряем те же плашки, не гадаем по числу символов. + // Невидимый слой не занимает место, не попадает в accessibility tree и не содержит действий. + afterRenderEffect(onCleanup => { + this.skills(); + const element = this.measureElement()?.nativeElement; + if (!element) return; + let active = true; + const measure = () => { + if (!active) return; + const chips = Array.from(element.querySelectorAll(".member-card__skill")); + const counter = element.querySelector(".member-card__more")!; + this.visibleCount.set( + fitMemberSkills( + element.getBoundingClientRect().width, + chips.map(c => c.getBoundingClientRect().width), + counter.getBoundingClientRect().width, + ), + ); + }; + measure(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); + observer?.observe(element); + void document.fonts?.ready.then(measure); + document.fonts?.addEventListener("loadingdone", measure); + onCleanup(() => { + active = false; + observer?.disconnect(); + document.fonts?.removeEventListener("loadingdone", measure); + }); + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.html b/projects/social_platform/src/app/ui/pages/members/members.component.html index bda89fa41..a6f33b190 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.component.html +++ b/projects/social_platform/src/app/ui/pages/members/members.component.html @@ -9,18 +9,14 @@
    @for (member of members(); track member.id) { - -
  • - -
  • -
    +
  • }
diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.scss b/projects/social_platform/src/app/ui/pages/members/members.component.scss index a6665729d..820a89e16 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.component.scss +++ b/projects/social_platform/src/app/ui/pages/members/members.component.scss @@ -22,6 +22,11 @@ @include responsive.apply-desktop { grid-template-columns: repeat(4, 2fr); } + + // Четыре карточки фиксированной ширины не помещаются рядом с фильтрами на узком desktop. + @media (min-width: responsive.$desktop) and (max-width: #{responsive.$container-md - 1px}) { + grid-template-columns: repeat(3, 2fr); + } } &__info { diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.ts b/projects/social_platform/src/app/ui/pages/members/members.component.ts index 0cd7ddb39..b9d24fafc 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.component.ts +++ b/projects/social_platform/src/app/ui/pages/members/members.component.ts @@ -9,13 +9,12 @@ import { OnInit, viewChild, } from "@angular/core"; -import { RouterLink } from "@angular/router"; import { ReactiveFormsModule } from "@angular/forms"; import { containerSm } from "@utils/responsive"; import { CommonModule } from "@angular/common"; import { SearchComponent } from "@ui/primitives/search/search.component"; import { MembersFiltersComponent } from "./members-filters/members-filters.component"; -import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { MemberCardComponent } from "./member-card/member-card.component"; import { BackComponent } from "@uilib"; import { ButtonComponent } from "@ui/primitives"; import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; @@ -34,9 +33,8 @@ import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profi ReactiveFormsModule, SearchComponent, CommonModule, - RouterLink, MembersFiltersComponent, - InfoCardComponent, + MemberCardComponent, BackComponent, ButtonComponent, SoonCardComponent, diff --git a/projects/social_platform/src/app/ui/pages/members/members.resolver.ts b/projects/social_platform/src/app/ui/pages/members/members.resolver.ts index 000b367de..c41240c40 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/members/members.resolver.ts @@ -7,21 +7,25 @@ import { GetMembersUseCase } from "@api/member/use-cases/get-members.use-case"; import { ApiPagination } from "@domain/other/api-pagination.model"; import { User } from "@domain/auth/user.model"; -/** Предзагружает список участников. */ -export const MembersResolver: ResolveFn> = () => { +import { normalizeMemberSearch } from "@api/member/member-search"; + +/** Предзагружает первые 20 участников с тем же поиском, который указан в URL. */ +export const MembersResolver: ResolveFn> = route => { const getMembersUseCase = inject(GetMembersUseCase); // Загружаем первые 20 участников (skip: 0, take: 20) - return getMembersUseCase.execute(0, 20).pipe( - map(result => - result.ok - ? result.value - : { - count: 0, - results: [], - next: "", - previous: "", - }, - ), - ); + return getMembersUseCase + .execute(0, 20, { fullname: normalizeMemberSearch(route.queryParams["fullname"]) }) + .pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + }, + ), + ); };