diff --git a/docs/qa/prod-members-2026-09-26/README.md b/docs/qa/prod-members-2026-09-26/README.md new file mode 100644 index 000000000..016e7e517 --- /dev/null +++ b/docs/qa/prod-members-2026-09-26/README.md @@ -0,0 +1,72 @@ + + +# PROD: страница «Участники» + +База master: `0cd4bb85ba8368695a3b0859e83630cb715067e5`. +Источники: DEV #377 (`3c67203663e2b4dd50b8e72df3d5fb53390c287f`), #378 (`63e67b3b59513f7a82f23122c4a51440251b280f`), #379 (`96a7a676f7d47194fa105fb1748a5bfc28b5b544`). + +Перенесены только API-фасад поиска участников и компоненты страницы. Их исходная версия master совпадала с DEV до #377. Backend-поиск подготовлен отдельно: [api #755](https://github.com/PROCOLLAB-github/api/pull/755). + +## Поведение + +- Поиск: нормализация пробелов, debounce 300 мс последнего текста, fullname в URL, актуальное число результатов, отмена старого запроса и игнорирование опоздавшей страницы старого поиска. Другие фильтры сохраняют смысл. +- Карточка: одна ссылка на профиль, имя/фамилия, вторичная информация, первый навык в исходном порядке и точный `+N`. Строка навыков всегда 22 px, даже когда навыков нет. Основной chip занимает свободную ширину, длинный текст обрезается с ellipsis и сохраняется в title. +- Mobile: одна широкая колонка на телефонах, отдельное окно прежних фильтров с Escape/возвратом фокуса; статистика под списком. На tablet — две колонки. Логика статистики, shared modal, React и DEV не изменены. + +## Финальное отличие от DEV #379 + +Высота карточки уменьшена **248 → 210 px**. Это единственное runtime-отличие перенесённых файлов от итогового DEV source. Измерено в настоящей Angular fixture: зазор skill→CTA **53 → 15 px**. Смещение кнопки не задано вручную: сохранён `margin-top: auto`. + +Не менялись ширина desktop 156 px, avatar 70×70 px, CSS `top: -35px; left: 50%`, CTA 32 px, типографика имени и структура карточки. Измеренный верх аватара относительно внешней рамки — −34 px из-за округления border, одинаковый до/после; desktop X = 43 px. Строка навыков начинается на Y = 128 px; CTA после — Y = 165 px. Все семь карточек имеют одинаковую высоту, CTA выровнены внутри каждого ряда. + +## Браузерная проверка + +Локальная fixture использует реальные MembersComponent, member-card, фильтры, стили office и приложения. Данные синтетические; API и фасад выдачи подменены, основной sidebar/header office не воспроизводятся целиком. Это проверка компонентов и геометрии, **не live E2E**. Безопасной авторизованной сессии PROD/DEV не было; live-поиск до deploy не проверен. Поиск проверен targeted-тестами фасада и PostgreSQL API-тестами. + +Семь примеров: без навыков; Java; PostgreSQL +1; длинный навык +4; Python +10; длинные имя/фамилия; специальность в две строки. + +| CSS viewport | Колонки | Ширина карточки | Высота | skill→CTA | Горизонтальное переполнение | +| ------------ | ------- | --------------- | ------ | --------- | --------------------------- | +| 1440 | 4 | 156 | 210 | 15 | 0 | +| 1280 | 4 | 156 | 210 | 15 | 0 | +| 1024 | 3 | 156 | 210 | 15 | 0 | +| 768 | 2 | 350,5 | 210 | 15 | 0 | +| 414 | 1 | 367 | 210 | 15 | 0 | +| 390 | 1 | 343 | 210 | 15 | 0 | +| 375 | 1 | 328 | 210 | 15 | 0 | + +Размеры учитывают обычную полосу прокрутки браузера. В mobile DOM нет правого sidebar; статистика измерена ниже списка. Окно фильтров открывается, Escape закрывает его и возвращает фокус. Вложенных интерактивных элементов внутри ссылки карточки нет. Console errors fixture: 0. Измерения: [до](geometry-before.json), [после](geometry-after.json). + +### Скриншоты + +До — финальный DEV #379 (248 px), после — PROD-перенос (210 px), одна и та же fixture и CSS viewport 1440×900. + +![До: 248 px](before-dev-248-desktop.jpg) +![После: 210 px](after-1440.jpg) +![1024: три колонки](after-1024.jpg) +![768: две колонки](after-768.jpg) +![390: одна колонка](after-390.jpg) +![375: минимальная проверенная ширина](after-375.jpg) +![Отдельные мобильные фильтры](filters-390.jpg) +![Статистика под списком](statistics-mobile-390.jpg) + +### Повторение fixture + +Скопировать primary-main.ts, primary-tsconfig.json, surface-index.html и serve-preview.py из этой папки в локальную tmp/. Они не подключены к runtime, CI или production build. + +```powershell +npx ng build social_platform --configuration=development --browser tmp/primary-main.ts --ts-config tmp/primary-tsconfig.json --index tmp/surface-index.html --output-path tmp/preview-after +Copy-Item tmp/preview-after/browser/surface-index.html tmp/preview-after/browser/index.html +# Запускать из tmp/preview-after/browser; сервер доступен только локально. +python ../../serve-preview.py 4351 +``` + +Открыть http://127.0.0.1:4351/office/members. Измерения выполнялись через getBoundingClientRect в браузере, без подмены размеров DOM. Изображения сохранены самим браузером; при узком viewport их физическая ширина может отличаться от CSS viewport. + +## Проверки + +Node 20.20.2; `npm ci` успешно. Targeted: `npm run test:ci -- --pool=forks projects/social_platform/src/app/api/member/facades/members-info.service.spec.ts projects/social_platform/src/app/ui/pages/members/` — 20/20 (5 файлов). + +`npm run lint:ts` — exit 0, 6 существующих предупреждений. Scoped Stylelint и Prettier для изменённых исходников — exit 0. `npm run build:prod` — exit 0; существующие Sass/CommonJS/budget warnings. На Windows штатный build:sprite с одинарными кавычками генерирует пустой sprite; только этот сгенерированный файл восстановлен из HEAD, в PR он не входит. Браузерная fixture собрана с исходным корректным sprite. Production scripts/dependencies не менялись. + +`npm run test:ci -- --pool=forks`: **1902/1902**, 395 файлов, exit 0; без NG0401 и unhandled errors. `git diff --check`: OK. Реальные данные не изменялись, merge/deploy не выполнялись. diff --git a/docs/qa/prod-members-2026-09-26/after-1024.jpg b/docs/qa/prod-members-2026-09-26/after-1024.jpg new file mode 100644 index 000000000..d4a1a026d Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/after-1024.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/after-1440.jpg b/docs/qa/prod-members-2026-09-26/after-1440.jpg new file mode 100644 index 000000000..678e367d6 Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/after-1440.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/after-375.jpg b/docs/qa/prod-members-2026-09-26/after-375.jpg new file mode 100644 index 000000000..9329e53b5 Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/after-375.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/after-390.jpg b/docs/qa/prod-members-2026-09-26/after-390.jpg new file mode 100644 index 000000000..a240c53c5 Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/after-390.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/after-768.jpg b/docs/qa/prod-members-2026-09-26/after-768.jpg new file mode 100644 index 000000000..4d2f3ff31 Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/after-768.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/before-dev-248-desktop.jpg b/docs/qa/prod-members-2026-09-26/before-dev-248-desktop.jpg new file mode 100644 index 000000000..6f41ec14d Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/before-dev-248-desktop.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/filters-390.jpg b/docs/qa/prod-members-2026-09-26/filters-390.jpg new file mode 100644 index 000000000..6619b181c Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/filters-390.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/geometry-after.json b/docs/qa/prod-members-2026-09-26/geometry-after.json new file mode 100644 index 000000000..7e9fde234 --- /dev/null +++ b/docs/qa/prod-members-2026-09-26/geometry-after.json @@ -0,0 +1,989 @@ +[ + { + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + } + ], + "overflow": 0, + "viewport": 1440, + "cardOverflow": false, + "chips": [ + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Java" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "PostgreSQL" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Ведение социальных сетей и контент-маркетинг" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Python" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "TypeScript" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Аналитика" + } + ], + "columns": "156px 156px 156px 156px", + "maxElementRight": 1232.484375, + "sidebar": true, + "hasStatisticsBlock": true + }, + { + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + } + ], + "overflow": 0, + "viewport": 1280, + "cardOverflow": false, + "chips": [ + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Java" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "PostgreSQL" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Ведение социальных сетей и контент-маркетинг" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Python" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "TypeScript" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Аналитика" + } + ], + "columns": "156px 156px 156px 156px", + "maxElementRight": 1152.484375, + "sidebar": true, + "hasStatisticsBlock": true + }, + { + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 478 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 738 + } + ], + "overflow": 0, + "viewport": 1024, + "cardOverflow": false, + "chips": [ + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Java" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "PostgreSQL" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Ведение социальных сетей и контент-маркетинг" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Python" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "TypeScript" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Аналитика" + } + ], + "columns": "180.156px 180.172px 180.172px", + "maxElementRight": 959, + "sidebar": true, + "hasStatisticsBlock": true + }, + { + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 140.25, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 350.5, + "y": 272 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 140.25, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 350.5, + "y": 272 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 140.25, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 350.5, + "y": 532 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 140.25, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 350.5, + "y": 532 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 140.25, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 350.5, + "y": 792 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 140.25, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 350.5, + "y": 792 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 140.25, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 350.5, + "y": 1052 + } + ], + "overflow": 0, + "viewport": 768, + "cardOverflow": false, + "chips": [ + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Java" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "PostgreSQL" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Ведение социальных сетей и контент-маркетинг" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Python" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "TypeScript" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Аналитика" + } + ], + "columns": "350.5px 350.5px", + "maxElementRight": 753, + "sidebar": false, + "hasStatisticsBlock": true + }, + { + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 148.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 367, + "y": 272 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 148.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 367, + "y": 532 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 148.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 367, + "y": 792 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 148.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 367, + "y": 1052 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 148.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 367, + "y": 1312 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 148.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 367, + "y": 1572 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 148.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 367, + "y": 1832 + } + ], + "overflow": 0, + "viewport": 414, + "cardOverflow": false, + "chips": [ + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Java" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "PostgreSQL" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Ведение социальных сетей и контент-маркетинг" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Python" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "TypeScript" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Аналитика" + } + ], + "columns": "367px", + "maxElementRight": 399, + "sidebar": false, + "hasStatisticsBlock": true + }, + { + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 136.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 343, + "y": 272 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 136.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 343, + "y": 532 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 136.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 343, + "y": 792 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 136.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 343, + "y": 1052 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 136.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 343, + "y": 1312 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 136.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 343, + "y": 1572 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 136.5, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 343, + "y": 1832 + } + ], + "overflow": 0, + "viewport": 390, + "cardOverflow": false, + "chips": [ + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Java" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "PostgreSQL" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Ведение социальных сетей и контент-маркетинг" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Python" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "TypeScript" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Аналитика" + } + ], + "columns": "343px", + "maxElementRight": 375, + "sidebar": false, + "hasStatisticsBlock": true + }, + { + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 129, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 328, + "y": 272 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 129, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 328, + "y": 532 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 129, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 328, + "y": 792 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 129, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 328, + "y": 1052 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 129, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 328, + "y": 1312 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 129, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 328, + "y": 1572 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 129, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 165, + "gap": 15, + "height": 210, + "skillHeight": 22, + "skillY": 128, + "width": 328, + "y": 1832 + } + ], + "overflow": 0, + "viewport": 375, + "cardOverflow": false, + "chips": [ + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Java" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "PostgreSQL" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Ведение социальных сетей и контент-маркетинг" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Python" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "TypeScript" + }, + { + "ellipsis": "ellipsis", + "nowrap": "nowrap", + "text": "Аналитика" + } + ], + "columns": "328px", + "maxElementRight": 360, + "sidebar": false, + "hasStatisticsBlock": true + } +] diff --git a/docs/qa/prod-members-2026-09-26/geometry-before.json b/docs/qa/prod-members-2026-09-26/geometry-before.json new file mode 100644 index 000000000..0ea07d2e9 --- /dev/null +++ b/docs/qa/prod-members-2026-09-26/geometry-before.json @@ -0,0 +1,104 @@ +{ + "cards": [ + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 203, + "gap": 53, + "height": 248, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 203, + "gap": 53, + "height": 248, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 203, + "gap": 53, + "height": 248, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 203, + "gap": 53, + "height": 248, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 218 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 203, + "gap": 53, + "height": 248, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 516 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 203, + "gap": 53, + "height": 248, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 516 + }, + { + "avatarHeight": 70, + "avatarWidth": 70, + "avatarX": 43, + "avatarY": -34, + "ctaHeight": 32, + "ctaY": 203, + "gap": 53, + "height": 248, + "skillHeight": 22, + "skillY": 128, + "width": 156, + "y": 516 + } + ], + "overflow": 0, + "viewport": 1440 +} diff --git a/docs/qa/prod-members-2026-09-26/primary-main.ts b/docs/qa/prod-members-2026-09-26/primary-main.ts new file mode 100644 index 000000000..f2bf53146 --- /dev/null +++ b/docs/qa/prod-members-2026-09-26/primary-main.ts @@ -0,0 +1,123 @@ +/** @format */ + +import "@angular/compiler"; +import { Component, provideZonelessChangeDetection, signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { BrowserTestingModule, platformBrowserTesting } from "@angular/platform-browser/testing"; +import { provideRouter, Router, RouterOutlet } from "@angular/router"; +import { provideHttpClient } from "@angular/common/http"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { API_URL } from "@corelib"; +import { FormBuilder } from "@angular/forms"; +import { MembersComponent } from "../projects/social_platform/src/app/ui/pages/members/members.component"; +import { MembersInfoService } from "@api/member/facades/members-info.service"; +import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-project-subscription.use-case"; +import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case"; +import { userFromRaw } from "@utils/userRaw"; +import { SearchesService } from "@api/searches/searches.service"; +const names = [ + ["Иван", "Иванов"], + ["Анна", "Петрова"], + ["Мария", "Смирнова"], + ["Дмитрий", "Соколов"], + ["Ольга", "Орлова"], + ["Александра-Елизавета", "Константинопольская"], + ["Евгений", "Морозов"], +]; +const skillSets = [ + [], + ["Java"], + ["PostgreSQL", "Node.js"], + ["Ведение социальных сетей и контент-маркетинг", "Редактура", "Видео", "Аналитика", "Дизайн"], + ["Python", ...Array.from({ length: 10 }, (_, i) => "Навык " + (i + 1))], + ["TypeScript"], + ["Аналитика"], +]; +const members = names.map(([firstName, lastName], i) => ({ + id: i + 100, + firstName, + lastName, + avatar: "/assets/images/profile/main.svg", + speciality: i === 6 ? "Исследователь образовательных технологий" : "Разработчик", + birthday: "2000-05-14", + skills: skillSets[i].map((name, id) => ({ + id, + name, + category: { id: 1, name: "Hard skills" }, + approves: [], + })), +})); +const suggestions = { + inlineSpecs: signal([]), + inlineSkills: signal([]), + onSearchSkill() { + this.inlineSkills.set([{ id: 1, name: "Angular" }]); + }, + onSearchSpec() { + this.inlineSpecs.set([{ id: 1, name: "Разработчик" }]); + }, +}; +const fb = new FormBuilder(); +const ui = { + members: signal(members.map(userFromRaw)), + searchForm: fb.group({ search: [""] }), + filterForm: fb.group({ + keySkill: [""], + speciality: [""], + age: [[null, null]], + isMosPolytechStudent: [false], + }), +}; +@Component({ + selector: "app-preview", + imports: [RouterOutlet], + styleUrls: ["../projects/social_platform/src/app/ui/pages/office/office.component.scss"], + template: `
+
+
+
+
+ +
+
+
+
+
+
`, +}) +class Preview {} +TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +TestBed.configureTestingModule({ + imports: [Preview], + providers: [ + { provide: AddProjectSubscriptionUseCase, useValue: {} }, + { provide: DeleteProjectSubscriptionUseCase, useValue: {} }, + provideZonelessChangeDetection(), + provideHttpClient(), + provideNoopAnimations(), + provideRouter([{ path: "office/members", component: MembersComponent }]), + { provide: API_URL, useValue: "/fixture-api" }, + { provide: SearchesService, useValue: suggestions }, + ], +}).overrideComponent(MembersComponent, { + set: { + providers: [ + { provide: MembersUIInfoService, useValue: ui }, + { + provide: MembersInfoService, + useValue: { initializationMembers() {}, initScroll() {}, redirectToProfile() {} }, + }, + { provide: ProfileDetailUIInfoService, useValue: {} }, + ], + }, +}); +TestBed.compileComponents().then(async () => { + const f = TestBed.createComponent(Preview); + document.body.appendChild(f.nativeElement); + f.autoDetectChanges(); + await TestBed.inject(Router).navigateByUrl("/office/members"); +}); diff --git a/docs/qa/prod-members-2026-09-26/primary-tsconfig.json b/docs/qa/prod-members-2026-09-26/primary-tsconfig.json new file mode 100644 index 000000000..e2fa4748b --- /dev/null +++ b/docs/qa/prod-members-2026-09-26/primary-tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { "outDir": "../out-tsc/surface-preview", "types": [] }, + "files": ["primary-main.ts"], + "include": ["../projects/**/*.d.ts"] +} diff --git a/docs/qa/prod-members-2026-09-26/serve-preview.py b/docs/qa/prod-members-2026-09-26/serve-preview.py new file mode 100644 index 000000000..c0a1c30d1 --- /dev/null +++ b/docs/qa/prod-members-2026-09-26/serve-preview.py @@ -0,0 +1,9 @@ +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit +class Handler(SimpleHTTPRequestHandler): + def do_GET(self): + if urlsplit(self.path).path.startswith(('/office/','/card-reference','/activity-reference')): + self.path='/index.html' + super().do_GET() +ThreadingHTTPServer(('127.0.0.1',int(sys.argv[1]) if len(sys.argv)>1 else 4307),Handler).serve_forever() diff --git a/docs/qa/prod-members-2026-09-26/statistics-mobile-390.jpg b/docs/qa/prod-members-2026-09-26/statistics-mobile-390.jpg new file mode 100644 index 000000000..632a20ecd Binary files /dev/null and b/docs/qa/prod-members-2026-09-26/statistics-mobile-390.jpg differ diff --git a/docs/qa/prod-members-2026-09-26/surface-index.html b/docs/qa/prod-members-2026-09-26/surface-index.html new file mode 100644 index 000000000..47cf8678f --- /dev/null +++ b/docs/qa/prod-members-2026-09-26/surface-index.html @@ -0,0 +1,12 @@ + + + + + + + Раздел проектов — локальная проверка + + + + + 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..cff162f7d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.html @@ -0,0 +1,37 @@ + + + +
+

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

+

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

+
+
    + @if (primarySkill(); as skill) { +
  • {{ 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..190e33cdd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss @@ -0,0 +1,145 @@ +/** @format */ +@use "styles/responsive"; + +:host { + display: block; + width: 156px; +} + +.member-card { + position: relative; + box-sizing: border-box; + display: block; + width: 156px; + height: 210px; + 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 { + min-width: 0; + height: 22px; + } + + &__skills { + display: flex; + gap: 4px; + align-items: center; + height: 22px; + padding: 0; + margin: 0; + list-style: none; + } + + &__skill, + &__more { + box-sizing: border-box; + display: inline-block; + min-width: 0; + max-width: 100%; + height: 22px; + padding: 3px 7px; + overflow: hidden; + font-size: 10px; + font-weight: 400; + line-height: 16px; + color: var(--accent-dark); + text-overflow: ellipsis; + white-space: nowrap; + background: var(--accent-light); + border-radius: 7px; + } + + &__skill { + flex: 1 1 auto; + text-align: left; + } + + &__more { + flex: 0 0 auto; + width: 40px; + font-weight: 600; + text-align: center; + } + + &__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); + } +} + +@media (max-width: #{responsive.$desktop - 1px}) { + :host, + .member-card { + width: 100%; + } + + .member-card { + padding-right: 16px; + padding-left: 16px; + } +} 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..8de92f65c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.spec.ts @@ -0,0 +1,83 @@ +/** @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 { MemberCardComponent } from "./member-card.component"; + +describe("Карточка каталога участников", () => { + it.each([0, 1, 2, 5, 11, 50])( + "показывает первый навык в исходном порядке и точный +N при %s навыках", + async count => { + await TestBed.configureTestingModule({ + imports: [MemberCardComponent], + providers: [provideRouter([])], + }).compileComponents(); + const f = TestBed.createComponent(MemberCardComponent); + const skills = Array.from({ length: count }, (_, id) => ({ + id, + name: id === 0 ? "Java" : id === 1 ? "Angular" : "Навык " + id, + })); + f.componentRef.setInput("member", userFromRaw({ id: 42, firstName: "Имя", skills })); + await f.whenStable(); + const root = f.nativeElement as HTMLElement; + expect(root.querySelectorAll(".member-card__skill")).toHaveLength(Math.min(count, 1)); + expect(root.querySelector(".member-card__skills-area")).not.toBeNull(); + const more = root.querySelector(".member-card__more"); + if (count > 1) { + expect(more?.textContent?.trim()).toBe("+" + (count - 1)); + expect(more).toBe(root.querySelector(".member-card__skills")?.lastElementChild); + expect(more?.getAttribute("title")).toContain("Angular"); + } else expect(more).toBeNull(); + if (count) { + expect(root.querySelector(".member-card__skill")?.textContent?.trim()).toBe("Java"); + expect(root.querySelector(".member-card__skill")?.getAttribute("title")).toBe( + skills[0].name, + ); + } + // Замена данных не требует ResizeObserver или ожидания загрузки шрифта. + f.componentRef.setInput("member", userFromRaw({ id: 42, skills: [] })); + await f.whenStable(); + expect(root.querySelector(".member-card__more")).toBeNull(); + expect(root.querySelector(".member-card__skills-area")).not.toBeNull(); + f.destroy(); + }, + ); + + 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: [{ id: 9, name: "Ведение социальных сетей и контент-маркетинг" }], + }), + ); + 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(1); + expect(root.querySelector(".member-card__skill")?.getAttribute("title")).toBe( + "Ведение социальных сетей и контент-маркетинг", + ); + expect(root.querySelector(".member-card__action")?.textContent?.trim()).toBe("Профиль"); + 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..5c48d294f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.ts @@ -0,0 +1,33 @@ +/** @format */ +import { ChangeDetectionStrategy, Component, computed, input } 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"; + +/** Карточка каталога участников; не меняет карточки команды и права внутри проекта. */ +@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 ?? []); + // Основной навык берём из порядка API: ширина карточки не меняет выбор и число скрытых навыков. + protected readonly primarySkill = computed(() => this.skills()[0]); + protected readonly hiddenCount = computed(() => Math.max(0, this.skills().length - 1)); + protected readonly hiddenNames = computed(() => + this.skills() + .slice(1) + .map(s => s.name) + .join(", "), + ); +} diff --git a/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.html b/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.html new file mode 100644 index 000000000..c314f2187 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.html @@ -0,0 +1,20 @@ + + + + diff --git a/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.scss b/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.scss new file mode 100644 index 000000000..4634c0cd5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.scss @@ -0,0 +1,80 @@ +/** @format */ + +// Portal находится вне host; стили ограничены только локальным классом окна фильтров. +.modal .modal__body.member-filters-dialog-body { + box-sizing: border-box; + display: block; + width: min(480px, calc(100vw - 32px)); + max-height: 90dvh; + padding: 20px; + overflow: auto; + overscroll-behavior: contain; +} + +.member-filters-dialog { + min-width: 0; + color: var(--black); + + &__header { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + button { + flex: 0 0 40px; + height: 40px; + font-size: 28px; + color: var(--dark-grey); + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--rounded-lg); + } + } + + &__content { + min-height: 220px; + + .filters__title { + display: none; + } + + .filters__titles { + justify-content: flex-end; + } + + .filters__clear { + min-height: 32px; + font-size: 13px; + } + + input { + min-height: 44px; + font-size: 16px; + } + } + + &__done { + width: 100%; + min-height: 44px; + margin-top: 20px; + font-size: 14px; + color: var(--white); + cursor: pointer; + background: var(--accent); + border: 0; + border-radius: var(--rounded-xxl); + } + + button:focus-visible { + outline: 2px solid var(--accent-dark); + outline-offset: 2px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.ts b/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.ts new file mode 100644 index 000000000..0ea0415d0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-filters-dialog/member-filters-dialog.component.ts @@ -0,0 +1,101 @@ +/** @format */ +import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + inject, + input, + output, + signal, + ViewChild, + ViewContainerRef, + ViewEncapsulation, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Overlay } from "@angular/cdk/overlay"; +import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { MembersFiltersComponent } from "../members-filters/members-filters.component"; + +/** Мобильная оболочка существующей формы фильтров; значения принадлежат странице, а не окну. */ +@Component({ + selector: "app-member-filters-dialog", + imports: [ModalComponent, A11yModule, MembersFiltersComponent], + templateUrl: "./member-filters-dialog.component.html", + styleUrl: "./member-filters-dialog.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MemberFiltersDialogComponent implements AfterViewInit { + readonly filterForm = input.required(); + readonly trigger = input(null); + readonly closed = output(); + protected readonly open = signal(true); + protected readonly attached = signal(false); + private readonly destroyRef = inject(DestroyRef); + private readonly overlayService = inject(Overlay); + + @ViewChild(ModalComponent) private modal!: ModalComponent; + @ViewChild(ModalComponent, { read: ViewContainerRef }) private modalContainer!: ViewContainerRef; + @ViewChild(CdkTrapFocus) private trap!: CdkTrapFocus; + @ViewChild("closeButton") private closeButton!: ElementRef; + + constructor() { + this.destroyRef.onDestroy(() => { + const overlay = this.modal?.overlayRef; + // Shared modal прикрепляет portal отложенно: уничтоженное окно не должно открыться повторно. + if (this.modal) this.modal.overlayRef = undefined; + overlay?.dispose(); + }); + } + + /** Фокус захватывается после attachment portal, как в существующих модальных окнах ленты. */ + ngAfterViewInit(): void { + const overlay = this.modal.overlayRef!; + const owner = this.modalContainer.injector.get(DestroyRef); + overlay.updateScrollStrategy(this.overlayService.scrollStrategies.block()); + overlay + .attachments() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + if (!this.open()) { + overlay.detach(); + return; + } + this.attached.set(true); + this.trap.focusTrap.attachAnchors(); + this.trap.enabled = true; + this.closeButton.nativeElement.focus(); + }); + overlay + .detachments() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.attached.set(false); + this.trap.enabled = false; + // Modal уничтожается раньше оболочки: демонтаж страницы не должен отправлять closed. + if (this.destroyRef.destroyed || owner.destroyed) return; + const trigger = this.trigger(); + if (trigger?.isConnected) trigger.focus(); + this.closed.emit(); + }); + overlay + .keydownEvents() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.close(); + } + }); + } + + /** Закрывает только представление; выбранные фильтры сохраняются и применяются прежним фасадом. */ + close(): void { + this.open.set(false); + } +} diff --git a/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.html b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.html index e905af83d..b365773d8 100644 --- a/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.html +++ b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.html @@ -3,7 +3,9 @@

фильтры

- сбросить +
diff --git a/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.scss b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.scss index cba283cf1..047171de6 100644 --- a/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.scss +++ b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.scss @@ -14,8 +14,11 @@ } &__clear { + padding: 0; color: var(--accent); cursor: pointer; + background: transparent; + border: 0; } &__title { diff --git a/projects/social_platform/src/app/ui/pages/members/members-mobile.spec.ts b/projects/social_platform/src/app/ui/pages/members/members-mobile.spec.ts new file mode 100644 index 000000000..07e8d1020 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/members-mobile.spec.ts @@ -0,0 +1,163 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { signal } from "@angular/core"; +import { By } from "@angular/platform-browser"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { provideRouter, Router } from "@angular/router"; +import { BreakpointObserver } from "@angular/cdk/layout"; +import { OverlayContainer } from "@angular/cdk/overlay"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { MembersInfoService } from "@api/member/facades/members-info.service"; +import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { SearchesService } from "@api/searches/searches.service"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { MembersComponent } from "./members.component"; +import { MemberFiltersDialogComponent } from "./member-filters-dialog/member-filters-dialog.component"; +import { MembersFiltersComponent } from "./members-filters/members-filters.component"; + +describe("Мобильное представление участников", () => { + const layout = new BehaviorSubject({ matches: true, breakpoints: {} }); + const redirect = vi.fn(); + beforeEach(async () => { + layout.next({ matches: true, breakpoints: {} }); + redirect.mockReset(); + await TestBed.configureTestingModule({ + imports: [MembersComponent], + providers: [ + provideRouter([]), + provideNoopAnimations(), + { provide: BreakpointObserver, useValue: { observe: () => layout, isMatched: () => true } }, + { + provide: SearchesService, + useValue: { + inlineSpecs: signal([]), + inlineSkills: signal([]), + onSearchSpec: vi.fn(), + onSearchSkill: vi.fn(), + }, + }, + ], + }) + .overrideComponent(MembersComponent, { + set: { + providers: [ + MembersUIInfoService, + { + provide: MembersInfoService, + useValue: { + initializationMembers: vi.fn(), + initScroll: vi.fn(), + redirectToProfile: redirect, + }, + }, + { provide: ProfileDetailUIInfoService, useValue: {} }, + ], + }, + }) + .compileComponents(); + }); + + afterEach(() => { + TestBed.inject(OverlayContainer).ngOnDestroy(); + TestBed.resetTestingModule(); + }); + + async function page() { + const f = TestBed.createComponent(MembersComponent); + document.body.appendChild(f.nativeElement); + f.autoDetectChanges(); + await f.whenStable(); + return f; + } + + async function finish(f: Awaited>) { + // Search CVA обновляет value через setTimeout: даём ему закончить до уничтожения fixture. + await new Promise(resolve => setTimeout(resolve, 0)); + f.destroy(); + } + + async function open(f: Awaited>) { + f.nativeElement.querySelector(".page__action--filters").click(); + f.detectChanges(); + const primitive = f.debugElement + .query(By.directive(MemberFiltersDialogComponent)) + .query(By.directive(ModalComponent)).componentInstance as ModalComponent; + await firstValueFrom(primitive.overlayRef!.attachments()); + await f.whenStable(); + return primitive; + } + + it("использует прежний search control и переход в профиль, не держит фильтры рядом со списком", async () => { + const f = await page(); + const ui = f.debugElement.injector.get(MembersUIInfoService); + const input = f.nativeElement.querySelector('input[type="search"]') as HTMLInputElement; + input.value = " Иван Иванов "; + input.dispatchEvent(new Event("input")); + expect(ui.searchForm.controls.search.value).toBe(" Иван Иванов "); + expect(f.debugElement.query(By.directive(MembersFiltersComponent))).toBeNull(); + f.nativeElement.querySelectorAll(".page__action")[1].click(); + expect(redirect).toHaveBeenCalledOnce(); + layout.next({ matches: false, breakpoints: {} }); + await f.whenStable(); + expect(f.debugElement.query(By.directive(SearchComponent))).not.toBeNull(); + expect(f.debugElement.query(By.directive(MembersFiltersComponent))).not.toBeNull(); + expect(f.nativeElement.querySelector(".page__action--filters")).toBeNull(); + await finish(f); + }); + + it("открывает ту же форму отдельно, сохраняет фильтры после закрытия, возвращает фокус", async () => { + const f = await page(); + const ui = f.debugElement.injector.get(MembersUIInfoService); + ui.filterForm.patchValue({ keySkill: "Java", speciality: "Разработчик" }); + const primitive = await open(f); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.getAttribute("aria-label")).toBe("Фильтры участников"); + expect(document.activeElement?.getAttribute("aria-label")).toBe("Закрыть фильтры"); + const filters = f.debugElement + .query(By.directive(MemberFiltersDialogComponent)) + .query(By.directive(MembersFiltersComponent)).componentInstance as MembersFiltersComponent; + expect(filters.filterForm()).toBe(ui.filterForm); + const detached = firstValueFrom(primitive.overlayRef!.detachments()); + dialog.querySelector(".member-filters-dialog__done")!.click(); + await detached; + await f.whenStable(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(ui.filterForm.controls.keySkill.value).toBe("Java"); + expect(document.activeElement).toBe(f.nativeElement.querySelector(".page__action--filters")); + await open(f); + expect(ui.filterForm.controls.speciality.value).toBe("Разработчик"); + await finish(f); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it.each(["Escape", "backdrop", "desktop"])("закрывает окно: %s", async reason => { + const f = await page(); + const primitive = await open(f); + const detached = firstValueFrom(primitive.overlayRef!.detachments()); + if (reason === "Escape") + document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + else if (reason === "backdrop") document.querySelector(".modal__overlay")!.click(); + else layout.next({ matches: false, breakpoints: {} }); + await detached; + await f.whenStable(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + await finish(f); + }); + + it("Сбросить вызывает существующий обработчик и очищает ту же форму", async () => { + const f = await page(); + const router = TestBed.inject(Router); + const navigate = vi.spyOn(router, "navigate").mockResolvedValue(true); + const ui = f.debugElement.injector.get(MembersUIInfoService); + ui.filterForm.patchValue({ keySkill: "Java", speciality: "Разработчик" }); + await open(f); + document.querySelector(".member-filters-dialog .filters__clear")!.click(); + await f.whenStable(); + expect(navigate).toHaveBeenCalledOnce(); + expect(ui.filterForm.controls.keySkill.value).toBeNull(); + expect(ui.filterForm.controls.speciality.value).toBeNull(); + await finish(f); + }); +}); 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..d16afe0fb 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 @@ -1,41 +1,83 @@ -
-
-
-
-
- -
- -
    - @for (member of members(); track member.id) { - -
  • - -
  • -
    - } -
+ @if (compactLayout()) { +
+ +
+ +
+ +
- -
- - перейти в профиль - - -
- - - + } @else { +
+
+
+
+ + + +
+
+ + перейти в профиль + +
+ + +
+
+
+ } + + +
    + @for (member of members(); track member.id) { +
  • + } +
+
+ +
+
-
+ + @if (filtersOpen() && compactLayout()) { + + }
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..2fbc8d98c 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 { @@ -51,3 +56,83 @@ width: 157px; } } + +@media (max-width: #{responsive.$desktop - 1px}) { + .page { + padding: 0 16px 24px; + + &__bar { + display: flex; + flex-direction: column; + gap: 20px; + } + + &__search { + min-width: 0; + margin-top: 10px; + } + + &__search-input { + box-sizing: border-box; + width: 100%; + min-width: 0; + height: 44px; + padding: 8px 12px; + font-size: 16px; + color: var(--black); + background: var(--light-white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__actions { + display: grid; + grid-template-columns: 1fr 1.6fr; + gap: 12px; + } + + &__action { + min-height: 44px; + padding: 8px; + font-size: 13px; + line-height: 18px; + color: var(--white); + cursor: pointer; + background: var(--accent); + border: 1px solid var(--accent); + border-radius: var(--rounded-xxl); + + &--filters { + color: var(--accent-dark); + background: var(--light-white); + } + } + + &__action:focus-visible, + &__search-input:focus-visible { + outline: 2px solid var(--accent-dark); + outline-offset: 3px; + } + + &__list { + grid-template-columns: minmax(0, 1fr); + width: 100%; + margin-top: 35px; + + li { + min-width: 0; + } + } + + &__statistics { + width: 157px; + margin: 4px auto 0; + } + } +} + +@include responsive.apply-tablet { + .page__list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} 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..a81a3ec41 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 @@ -8,14 +8,18 @@ import { inject, OnInit, viewChild, + effect, + signal, } from "@angular/core"; -import { RouterLink } from "@angular/router"; +import { BreakpointObserver } from "@angular/cdk/layout"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { map } from "rxjs"; 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"; @@ -23,6 +27,7 @@ import { MembersInfoService } from "@api/member/facades/members-info.service"; import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; import { AppRoutes } from "@api/paths/app-routes"; import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { MemberFiltersDialogComponent } from "./member-filters-dialog/member-filters-dialog.component"; /** Список участников с поиском, фильтрацией и бесконечной прокруткой. */ @Component({ @@ -34,12 +39,12 @@ import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profi ReactiveFormsModule, SearchComponent, CommonModule, - RouterLink, MembersFiltersComponent, - InfoCardComponent, + MemberCardComponent, BackComponent, ButtonComponent, SoonCardComponent, + MemberFiltersDialogComponent, ], providers: [MembersInfoService, MembersUIInfoService, ProfileDetailUIInfoService], }) @@ -58,6 +63,26 @@ export class MembersComponent implements OnInit, AfterViewInit { protected readonly containerSm = containerSm; // Брейкпоинт для мобильных устройств protected readonly appWidth = window.innerWidth; // Ширина окна браузера protected readonly AppRoutes = AppRoutes; + private readonly breakpoints = inject(BreakpointObserver); + protected readonly compactLayout = toSignal( + this.breakpoints.observe("(max-width: 999px)").pipe(map(state => state.matches)), + { initialValue: this.breakpoints.isMatched("(max-width: 999px)") }, + ); + protected readonly filtersOpen = signal(false); + protected filtersTrigger: HTMLElement | null = null; + + constructor() { + // При переходе к desktop фильтры снова видны в sidebar; мобильное окно больше не нужно. + effect(() => { + if (!this.compactLayout()) this.filtersOpen.set(false); + }); + } + + /** Открывает мобильное представление той же формы, не сбрасывая выбранные фильтры. */ + protected openFilters(trigger: HTMLElement): void { + this.filtersTrigger = trigger; + this.filtersOpen.set(true); + } ngOnInit(): void { this.membersInfoService.initializationMembers(); 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: "", + }, + ), + ); };