diff --git a/docs/project-cards-activity/README-prod.md b/docs/project-cards-activity/README-prod.md new file mode 100644 index 000000000..f3152555a --- /dev/null +++ b/docs/project-cards-activity/README-prod.md @@ -0,0 +1,17 @@ + + +# Проверка переноса блока проектов в production-ветку + +Ниже приведены браузерные снимки финального состояния. Для блока активности использован Storybook с кодом текущей ветки; для разделов подписок, витрины и приглашений — снимки проверенного DEV-исправления #368, перенесённого в эту ветку без изменения разметки этих разделов. + +| Сценарий | Снимок | Проверка | +| --------------------- | ------------------------------------------------------ | ------------------------------------------------------------ | +| Dashboard | [Открыть](screenshots/prod-dashboard-after.png) | Блок активности справа, заголовок буквально «моя активность» | +| Мои проекты | [Открыть](screenshots/prod-my-projects-after.png) | Блок активности после кнопки создания | +| Подписки | [Открыть](screenshots/subscriptions-after.png) | Отрасль не отображается в карточках | +| Все проекты | [Открыть](screenshots/all-after.png) | Отрасль не отображается; фильтр по отрасли остаётся | +| Приглашения | [Открыть](screenshots/invites-after.png) | Отрасль не отображается в карточках | +| Мобильный dashboard | [Открыть](screenshots/prod-mobile-dashboard-after.png) | Блок не перекрывает основной контент | +| Мобильные приглашения | [Открыть](screenshots/mobile-invites-after.png) | Карточки без отрасли | + +Во всех трёх снимках блока активности браузер подтвердил размеры 156 × 180 px, исходный текст заголовка `моя активность` и отсутствие `text-transform`. diff --git a/docs/project-cards-activity/screenshots/all-after.png b/docs/project-cards-activity/screenshots/all-after.png new file mode 100644 index 000000000..89adf6cb2 Binary files /dev/null and b/docs/project-cards-activity/screenshots/all-after.png differ diff --git a/docs/project-cards-activity/screenshots/invites-after.png b/docs/project-cards-activity/screenshots/invites-after.png new file mode 100644 index 000000000..d6e1ca3dc Binary files /dev/null and b/docs/project-cards-activity/screenshots/invites-after.png differ diff --git a/docs/project-cards-activity/screenshots/mobile-invites-after.png b/docs/project-cards-activity/screenshots/mobile-invites-after.png new file mode 100644 index 000000000..09a9ba990 Binary files /dev/null and b/docs/project-cards-activity/screenshots/mobile-invites-after.png differ diff --git a/docs/project-cards-activity/screenshots/prod-dashboard-after.png b/docs/project-cards-activity/screenshots/prod-dashboard-after.png new file mode 100644 index 000000000..9899caeeb Binary files /dev/null and b/docs/project-cards-activity/screenshots/prod-dashboard-after.png differ diff --git a/docs/project-cards-activity/screenshots/prod-mobile-dashboard-after.png b/docs/project-cards-activity/screenshots/prod-mobile-dashboard-after.png new file mode 100644 index 000000000..edd4b3052 Binary files /dev/null and b/docs/project-cards-activity/screenshots/prod-mobile-dashboard-after.png differ diff --git a/docs/project-cards-activity/screenshots/prod-my-projects-after.png b/docs/project-cards-activity/screenshots/prod-my-projects-after.png new file mode 100644 index 000000000..27c93286c Binary files /dev/null and b/docs/project-cards-activity/screenshots/prod-my-projects-after.png differ diff --git a/docs/project-cards-activity/screenshots/subscriptions-after.png b/docs/project-cards-activity/screenshots/subscriptions-after.png new file mode 100644 index 000000000..c85e389e3 Binary files /dev/null and b/docs/project-cards-activity/screenshots/subscriptions-after.png differ diff --git a/projects/social_platform/src/app/api/project/facades/projects-info.service.spec.ts b/projects/social_platform/src/app/api/project/facades/projects-info.service.spec.ts new file mode 100644 index 000000000..6f29b87e7 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/projects-info.service.spec.ts @@ -0,0 +1,77 @@ +/** @format */ + +import { signal } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; +import { BehaviorSubject, of, Subject } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { NavService } from "@api/shared/nav.service"; +import { InviteInfoService } from "@api/invite/facades/invite-info.service"; +import { CreateProjectUseCase } from "../use-cases/create-project.use-case"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { ProjectCount, ProjectCountLoadState } from "@domain/project/project.model"; +import { ProjectsUIInfoService } from "./ui/projects-ui-info.service"; +import { ProjectsInfoService } from "./projects-info.service"; + +describe("ProjectsInfoService: загрузка активности", () => { + const count: ProjectCount = { + all: 12, + my: 7, + subs: 3, + myLeader: 4, + myInProgram: 2, + mySubmitted: 1, + }; + + function setup(initialUrl: string) { + const events = new Subject(); + const router = { + url: initialUrl, + events, + navigate: vi.fn(() => Promise.resolve(true)), + }; + const repository = { + count$: new BehaviorSubject(count), + countState$: new BehaviorSubject("idle"), + refreshCount: vi.fn(() => of(count)), + }; + const searchForm = new FormGroup({ search: new FormControl("") }); + + TestBed.configureTestingModule({ + providers: [ + ProjectsInfoService, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: { snapshot: {}, parent: null } }, + { provide: LoggerService, useValue: { debug: vi.fn(), warn: vi.fn() } }, + { provide: NavService, useValue: { setNavTitle: vi.fn() } }, + { provide: InviteInfoService, useValue: { ensureLoaded: vi.fn() } }, + { provide: ProjectsUIInfoService, useValue: { searchForm, myInvites: signal([]) } }, + { provide: CreateProjectUseCase, useValue: { execute: vi.fn() } }, + { provide: ProjectRepositoryPort, useValue: repository }, + ], + }); + + return { service: TestBed.inject(ProjectsInfoService), repository, router, events }; + } + + it.each(["/office/projects/dashboard", "/office/projects/my"])( + "запрашивает count при входе на %s", + url => { + const { service, repository } = setup(url); + service.initializationProjects(); + expect(repository.refreshCount).toHaveBeenCalledOnce(); + }, + ); + + it("не запрашивает count в остальных разделах и загружает при переходе в my", () => { + const { service, repository, router, events } = setup("/office/projects/subscriptions"); + service.initializationProjects(); + expect(repository.refreshCount).not.toHaveBeenCalled(); + + router.url = "/office/projects/my"; + events.next(new NavigationEnd(1, router.url, router.url)); + + expect(repository.refreshCount).toHaveBeenCalledOnce(); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/projects-info.service.ts b/projects/social_platform/src/app/api/project/facades/projects-info.service.ts index fd5cc6d3e..1f9142a98 100644 --- a/projects/social_platform/src/app/api/project/facades/projects-info.service.ts +++ b/projects/social_platform/src/app/api/project/facades/projects-info.service.ts @@ -9,7 +9,8 @@ import { ProjectsUIInfoService } from "./ui/projects-ui-info.service"; import { CreateProjectUseCase } from "../use-cases/create-project.use-case"; import { AppRoutes } from "@api/paths/app-routes"; import { InviteInfoService } from "@api/invite/facades/invite-info.service"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; /** Координирует верхний уровень раздела проектов: табы, поиск и создание проекта. */ @Injectable() @@ -24,6 +25,7 @@ export class ProjectsInfoService { private readonly projectsUIInfoService = inject(ProjectsUIInfoService); private readonly createProjectUseCase = inject(CreateProjectUseCase); + private readonly projectRepository = inject(ProjectRepositoryPort); private readonly url = signal(this.router.url); private readonly searchForm = this.projectsUIInfoService.searchForm; @@ -33,11 +35,16 @@ export class ProjectsInfoService { readonly isSubs = computed(() => this.url().includes("/subscriptions")); readonly isInvites = computed(() => this.url().includes("/invites")); readonly isDashboard = computed(() => this.url().includes("/dashboard")); + readonly projectCount = toSignal(this.projectRepository.count$, { requireSync: true }); + readonly projectCountState = toSignal(this.projectRepository.countState$, { + requireSync: true, + }); initializationProjects(): void { this.navService.setNavTitle("Проекты"); this.inviteInfoService.ensureLoaded(); + this.refreshProjectCountIfVisible(); this.searchForm .get("search") @@ -65,7 +72,32 @@ export class ProjectsInfoService { filter(event => event instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef), ) - .subscribe(() => this.url.set(this.router.url)); + .subscribe(() => { + const previousSection = this.activitySection(this.url()); + this.url.set(this.router.url); + const currentSection = this.activitySection(this.url()); + + if (currentSection && currentSection !== previousSection) { + this.refreshProjectCountIfVisible(); + } + }); + } + + private activitySection(url: string): "dashboard" | "my" | null { + if (url.includes("/dashboard")) return "dashboard"; + if (url.includes("/my")) return "my"; + return null; + } + + private refreshProjectCountIfVisible(): void { + if (!this.isMy() && !this.isDashboard()) return; + + this.projectRepository + .refreshCount() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: error => this.logger.warn("Не удалось загрузить статистику проектов", error), + }); } addProject(): void { diff --git a/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts index 24c9dbdd0..1a61a0073 100644 --- a/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts +++ b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts @@ -1,7 +1,7 @@ /** @format */ import { BehaviorSubject, Observable } from "rxjs"; -import { Project, ProjectCount } from "../project.model"; +import { Project, ProjectCount, ProjectCountLoadState } from "../project.model"; import { ApiPagination } from "../../other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; import { ProjectCoverReset } from "../project-cover.model"; @@ -9,6 +9,7 @@ import { ProjectCoverReset } from "../project-cover.model"; /** Порт репозитория проектов — контракт CRUD. Реализуется в infrastructure/repository/project. */ export abstract class ProjectRepositoryPort { abstract readonly count$: BehaviorSubject; + abstract readonly countState$: BehaviorSubject; abstract getAll(params?: HttpParams): Observable>; abstract getOne(id: number): Observable; diff --git a/projects/social_platform/src/app/domain/project/project.model.ts b/projects/social_platform/src/app/domain/project/project.model.ts index 6b6e6d669..b08a904eb 100644 --- a/projects/social_platform/src/app/domain/project/project.model.ts +++ b/projects/social_platform/src/app/domain/project/project.model.ts @@ -97,8 +97,13 @@ export class ProjectCount { all!: number; my!: number; subs!: number; + myLeader!: number; + myInProgram!: number; + mySubmitted!: number; } +export type ProjectCountLoadState = "idle" | "loading" | "loaded" | "error"; + export class ProjectStep { id!: number; name!: string; diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts index 95e5146dc..c3451f820 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts @@ -52,6 +52,9 @@ export interface ProjectCountDto { all: number; my: number; subs: number; + myLeader: number; + myInProgram: number; + mySubmitted: number; } export interface ProjectListDto { diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-count-contract.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-count-contract.spec.ts new file mode 100644 index 000000000..9193c1a0e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-count-contract.spec.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { CamelcaseInterceptor } from "@core/lib/interceptors/camelcase.interceptor"; +import { API_URL } from "@core/lib/providers"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProjectHttpAdapter } from "./project-http.adapter"; + +describe("Контракт статистики проектов через общий interceptor", () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "https://local.test" }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + { provide: LoggerService, useValue: { warn: vi.fn() } }, + ], + }), + ); + + afterEach(() => TestBed.inject(HttpTestingController).verify()); + + it("преобразует snake_case метрик в camelCase Angular domain", () => { + TestBed.inject(ProjectHttpAdapter) + .fetchCount() + .subscribe(value => { + expect(value).toEqual({ + all: 12, + my: 7, + myLeader: 4, + myInProgram: 2, + mySubmitted: 1, + }); + }); + + const request = TestBed.inject(HttpTestingController).expectOne( + "https://local.test/projects/count/", + ); + expect(request.request.method).toBe("GET"); + request.flush({ + all: 12, + my: 7, + my_leader: 4, + my_in_program: 2, + my_submitted: 1, + }); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts index 6edfd5671..80d9dff08 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts @@ -1,7 +1,7 @@ /** @format */ import { TestBed } from "@angular/core/testing"; -import { of, Subject } from "rxjs"; +import { of, Subject, throwError } from "rxjs"; import { HttpParams } from "@angular/common/http"; import { ProjectRepository } from "./project.repository"; import { ProjectHttpAdapter } from "../../adapters/project/project-http.adapter"; @@ -17,6 +17,7 @@ import { removeProjectCollaborator } from "@domain/project/events/remove-project import { sendVacancyResponse } from "@domain/vacancy/events/send-vacancy-response.event"; import { acceptVacancyResponse } from "@domain/vacancy/events/accept-vacancy-response.event"; import { rejectVacancyResponse } from "@domain/vacancy/events/reject-vacancy-response.event"; +import { acceptInvite } from "@domain/invite/events/accept-invite.event"; describe("ProjectRepository", () => { let repository: ProjectRepository; @@ -39,8 +40,19 @@ describe("ProjectRepository", () => { }); eventBus = TestBed.inject(EventBus); repository = TestBed.inject(ProjectRepository); + adapter.fetchCount.mockReturnValue(of(activityCount())); } + const activityCount = (overrides: Partial = {}): ProjectCount => ({ + all: 0, + my: 0, + subs: 0, + myLeader: 0, + myInProgram: 0, + mySubmitted: 0, + ...overrides, + }); + const page = (): ApiPagination => ({ count: 1, next: "", @@ -97,17 +109,41 @@ describe("ProjectRepository", () => { it("refreshCount мапит в ProjectCount и пушит в count$", () => new Promise(done => { setup(); - adapter.fetchCount.mockReturnValue(of({ my: 1, all: 2, subs: 3 } as ProjectCount)); + adapter.fetchCount.mockReturnValue( + of( + activityCount({ + my: 1, + all: 2, + subs: 3, + myLeader: 4, + myInProgram: 5, + mySubmitted: 6, + }), + ), + ); repository.refreshCount().subscribe(count => { expect(count).toBeInstanceOf(ProjectCount); expect(repository.count$.getValue().my).toBe(1); expect(repository.count$.getValue().all).toBe(2); expect(repository.count$.getValue().subs).toBe(3); + expect(repository.count$.getValue().myLeader).toBe(4); + expect(repository.count$.getValue().myInProgram).toBe(5); + expect(repository.count$.getValue().mySubmitted).toBe(6); + expect(repository.countState$.getValue()).toBe("loaded"); done(); }); })); + it("refreshCount помечает недоступный count API как error", () => { + setup(); + adapter.fetchCount.mockReturnValue(throwError(() => new Error("boom"))); + + repository.refreshCount().subscribe({ error: () => undefined }); + + expect(repository.countState$.getValue()).toBe("error"); + }); + it("update мапит ответ в Project и инвалидирует кеш", () => new Promise(done => { setup(); @@ -150,18 +186,20 @@ describe("ProjectRepository", () => { expect(adapter.deleteOne).toHaveBeenCalledExactlyOnceWith(42); }); - it("ProjectCreated увеличивает count.my", () => { + it("ProjectCreated перечитывает authoritative count", () => { setup(); - repository.count$.next({ my: 1, all: 0, subs: 0 }); + adapter.fetchCount.mockReturnValue(of(activityCount({ my: 2, myLeader: 2 }))); eventBus.emit(projectCreated({ id: 1 } as Project)); expect(repository.count$.getValue().my).toBe(2); + expect(repository.count$.getValue().myLeader).toBe(2); + expect(adapter.fetchCount).toHaveBeenCalledOnce(); }); - it("ProjectDeleted уменьшает count.my и инвалидирует кеш", () => { + it("ProjectDeleted перечитывает count и инвалидирует кеш", () => { setup(); adapter.fetchOne.mockReturnValue(of({ id: 7 } as ProjectDto)); repository.getOne(7).subscribe(); - repository.count$.next({ my: 2, all: 0, subs: 0 }); + adapter.fetchCount.mockReturnValue(of(activityCount({ my: 1 }))); eventBus.emit(projectDeleted(7)); @@ -172,18 +210,28 @@ describe("ProjectRepository", () => { it("ProjectSubscribed увеличивает count.subs", () => { setup(); - repository.count$.next({ my: 0, all: 0, subs: 1 }); + repository.count$.next(activityCount({ subs: 1 })); eventBus.emit(projectSubscribed(7)); expect(repository.count$.getValue().subs).toBe(2); }); it("ProjectUnSubscribed уменьшает count.subs (не ниже 0) и инвалидирует", () => { setup(); - repository.count$.next({ my: 0, all: 0, subs: 0 }); + repository.count$.next(activityCount()); eventBus.emit(projectUnSubscribed(7)); expect(repository.count$.getValue().subs).toBe(0); }); + it("AcceptInvite перечитывает count после успешного доменного события", () => { + setup(); + adapter.fetchCount.mockReturnValue(of(activityCount({ my: 1 }))); + + eventBus.emit(acceptInvite(5, 7, 11, "Участник")); + + expect(adapter.fetchCount).toHaveBeenCalledOnce(); + expect(repository.count$.getValue().my).toBe(1); + }); + it("RemoveProjectCollaborator инвалидирует кеш проекта", () => { setup(); adapter.fetchOne.mockReturnValue(of({ id: 7 } as ProjectDto)); diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts index 263188cd5..00c6ae6fd 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts @@ -1,8 +1,8 @@ /** @format */ import { inject, Injectable } from "@angular/core"; -import { BehaviorSubject, map, Observable, tap } from "rxjs"; -import { Project, ProjectCount } from "@domain/project/project.model"; +import { BehaviorSubject, catchError, map, Observable, tap, throwError } from "rxjs"; +import { Project, ProjectCount, ProjectCountLoadState } from "@domain/project/project.model"; import { ProjectHttpAdapter } from "../../adapters/project/project-http.adapter"; import { ApiPagination } from "@domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; @@ -28,7 +28,15 @@ import { defer, finalize } from "rxjs"; @Injectable({ providedIn: "root" }) export class ProjectRepository implements ProjectRepositoryPort { private readonly entityCache = new EntityCache(); - readonly count$ = new BehaviorSubject({ my: 0, all: 0, subs: 0 }); + readonly count$ = new BehaviorSubject({ + my: 0, + all: 0, + subs: 0, + myLeader: 0, + myInProgram: 0, + mySubmitted: 0, + }); + readonly countState$ = new BehaviorSubject("idle"); private readonly projectAdapter = inject(ProjectHttpAdapter); private readonly eventBus = inject(EventBus); @@ -38,20 +46,14 @@ export class ProjectRepository implements ProjectRepositoryPort { } private initializeEventListeners(): void { - // События синхронизируют счётчики и кеш деталей без повторной загрузки всех списков. + // Изменения состава проектов требуют повторной authoritative-агрегации на backend. this.eventBus.on("ProjectCreated").subscribe(() => { - this.count$.next({ - ...this.count$.getValue(), - my: this.count$.getValue().my + 1, - }); + this.refreshCountAfterProjectChange(); }); this.eventBus.on("ProjectDeleted").subscribe(event => { this.entityCache.invalidate(event.payload.projectId); - this.count$.next({ - ...this.count$.getValue(), - my: Math.max(0, this.count$.getValue().my - 1), - }); + this.refreshCountAfterProjectChange(); }); this.eventBus.on("ProjectSubscribed").subscribe({ @@ -82,6 +84,7 @@ export class ProjectRepository implements ProjectRepositoryPort { this.eventBus.on("AcceptInvite").subscribe({ next: event => { this.entityCache.invalidate(event.payload.projectId); + this.refreshCountAfterProjectChange(); }, }); @@ -124,10 +127,26 @@ export class ProjectRepository implements ProjectRepositoryPort { } refreshCount(): Observable { - return this.projectAdapter.fetchCount().pipe( - map(dto => plainToInstance(ProjectCount, dto)), - tap(count => this.count$.next(count)), - ); + return defer(() => { + this.countState$.next("loading"); + + return this.projectAdapter.fetchCount().pipe( + map(dto => plainToInstance(ProjectCount, dto)), + tap(count => { + this.count$.next({ ...this.count$.getValue(), ...count }); + this.countState$.next("loaded"); + }), + catchError(error => { + this.countState$.next("error"); + return throwError(() => error); + }), + ); + }); + } + + /** После доменного изменения перечитывает все взаимосвязанные метрики одним запросом. */ + private refreshCountAfterProjectChange(): void { + this.refreshCount().subscribe({ error: () => undefined }); } update(id: number, data: Partial): Observable { diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.spec.ts index 5e8882dbf..d6a350a39 100644 --- a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.spec.ts @@ -120,7 +120,10 @@ describe("ProjectsDashboard: контексты карточек", () => { } else { expect(c.isSubscribed).toBe(true); expect(el.nativeElement.querySelector(".card__status")).toBeNull(); - expect(!!el.nativeElement.querySelector(".card__context--industry")).toBe(i !== 3); + expect(el.nativeElement.querySelector(".card__context--industry")).toBeNull(); + expect(el.nativeElement.querySelector(".card__project-link").hasAttribute("title")).toBe( + false, + ); expect( el.nativeElement.querySelector(".card__project-action-label").textContent.trim(), ).toBe("Открыть"); diff --git a/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.html b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.html new file mode 100644 index 000000000..cb4368dde --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.html @@ -0,0 +1,25 @@ + + +
+

моя активность

+ +
+ @for (metric of metrics(); track metric.label) { +
+
+ + {{ metric.label }} +
+
+ @if (isLoaded()) { + {{ metric.value }} + } @else { + — + } +
+
+ } +
+
diff --git a/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.scss b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.scss new file mode 100644 index 000000000..8e05edbc8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.scss @@ -0,0 +1,82 @@ +:host { + display: block; + width: 156px; + height: 180px; +} + +.activity { + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 12px 10px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__title { + margin: 0 0 9px; + font-size: 14px; + font-weight: 600; + line-height: 18px; + color: var(--accent); + } + + &__metrics { + display: grid; + flex: 1; + grid-template-rows: repeat(4, minmax(0, 1fr)); + min-height: 0; + margin: 0; + } + + &__metric { + display: flex; + align-items: center; + justify-content: space-between; + + & + & { + border-top: 0.5px solid var(--medium-grey-for-outline); + } + } + + &__label { + display: flex; + gap: 4px; + align-items: center; + min-width: 0; + font-size: 11px; + font-weight: 400; + line-height: 14px; + color: var(--black); + } + + &__icon { + display: grid; + flex: 0 0 22px; + place-items: center; + width: 22px; + height: 22px; + color: var(--accent); + background-color: var(--accent-light); + border-radius: 50%; + } + + &__value { + flex: 0 0 auto; + + // Очень большие числа не вытесняют подпись; полное значение остаётся в DOM и title. + max-width: 45px; + margin: 0 0 0 4px; + overflow: hidden; + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 17px; + color: var(--accent); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts new file mode 100644 index 000000000..fea2b21f8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts @@ -0,0 +1,88 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectCount } from "@domain/project/project.model"; +import { ProjectActivityCardComponent } from "./project-activity-card.component"; + +describe("ProjectActivityCardComponent", () => { + const count = (overrides: Partial = {}): ProjectCount => ({ + all: 0, + my: 3, + subs: 0, + myLeader: 2, + myInProgram: 1, + mySubmitted: 4, + ...overrides, + }); + + function render(value = count(), state: "loaded" | "loading" | "error" = "loaded") { + const fixture = TestBed.createComponent(ProjectActivityCardComponent); + fixture.componentRef.setInput("count", value); + fixture.componentRef.setInput("state", state); + fixture.detectChanges(); + return fixture; + } + + it("показывает четыре значения authoritative count", () => { + const element = render().nativeElement as HTMLElement; + const rows = Array.from(element.querySelectorAll(".activity__metric")); + + expect(rows).toHaveLength(4); + expect(rows.map(row => row.textContent?.replace(/\s+/g, " ").trim())).toEqual([ + "Проектов 3", + "Я лидер 2", + "В программе 1", + "Сдано 4", + ]); + }); + + it("отображает нули как числа и не содержит текст старой заглушки", () => { + const element = render(count({ my: 0, myLeader: 0, myInProgram: 0, mySubmitted: 0 })) + .nativeElement as HTMLElement; + + expect(element.querySelectorAll(".activity__value")).toHaveLength(4); + expect( + Array.from(element.querySelectorAll(".activity__value")).map(x => x.textContent?.trim()), + ).toEqual(["0", "0", "0", "0"]); + expect(element.textContent).not.toContain("скоро"); + expect(element.textContent).not.toContain("пока закрыто"); + expect(element.textContent).not.toContain("Подробнее"); + expect(element.querySelector("button, a")).toBeNull(); + }); + + it.each(["loading", "error"] as const)("не показывает выдуманные числа в состоянии %s", state => { + const element = render(count(), state).nativeElement as HTMLElement; + expect( + Array.from(element.querySelectorAll(".activity__value")).map(x => x.textContent?.trim()), + ).toEqual(["—", "—", "—", "—"]); + }); + + it("сохраняет заголовок в обычном регистре и декоративные иконки", () => { + const element = render().nativeElement as HTMLElement; + expect(element.querySelector("h2")?.textContent?.trim()).toBe("моя активность"); + expect(element.querySelectorAll('.activity__icon[aria-hidden="true"]')).toHaveLength(4); + }); + + it.each([0, 26, 123, 9999, 999999999999])( + "не подменяет значение %s сокращённым числом", + value => { + const element = render(count({ my: value })).nativeElement as HTMLElement; + const counter = element.querySelector(".activity__value")!; + expect(counter.textContent?.trim()).toBe(String(value)); + expect(counter.title).toBe(String(value)); + }, + ); + + it("при повторной загрузке убирает устаревшие числа, включая title", () => { + const fixture = render(count({ my: 9999 })); + for (const state of ["loading", "error"] as const) { + fixture.componentRef.setInput("state", state); + fixture.detectChanges(); + const values = Array.from( + fixture.nativeElement.querySelectorAll(".activity__value"), + ) as HTMLElement[]; + expect(values.map(element => element.textContent?.trim())).toEqual(["—", "—", "—", "—"]); + expect(values.every(element => !element.hasAttribute("title"))).toBe(true); + } + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.stories.ts b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.stories.ts new file mode 100644 index 000000000..a22bbc175 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.stories.ts @@ -0,0 +1,256 @@ +/** @format */ + +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { ProjectCount } from "@domain/project/project.model"; +import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { ProjectActivityCardComponent } from "./project-activity-card.component"; + +const count: ProjectCount = { + all: 24, + my: 7, + subs: 3, + myLeader: 4, + myInProgram: 2, + mySubmitted: 1, +}; + +const fixtureStyles = ` + +`; + +const projects = ` +
+
+

Цифровая платформа проекта

+
+
+
+
+

Образовательная инициатива

+
+
+
+
+`; + +const meta: Meta = { + title: "Projects/Моя активность", + component: ProjectActivityCardComponent, + decorators: [ + moduleMetadata({ + imports: [ProjectActivityCardComponent, SoonCardComponent], + }), + ], + parameters: { layout: "fullscreen" }, +}; + +export default meta; + +type Story = StoryObj; + +export const Card: Story = { + name: "Карточка", + args: { count, state: "loaded" }, +}; + +export const DashboardBefore: Story = { + name: "Dashboard · до", + render: () => ({ + template: ` + ${fixtureStyles} +
+

Проекты · dashboard

+
+ ${projects} + +
+
+ `, + }), +}; + +export const DashboardAfter: Story = { + name: "Dashboard · после", + render: () => ({ + props: { count }, + template: ` + ${fixtureStyles} +
+

Проекты · dashboard

+
+ ${projects} + +
+
+ `, + }), +}; + +export const MyProjectsBefore: Story = { + name: "Мои проекты · до", + render: () => ({ + template: ` + ${fixtureStyles} +
+

Проекты · мои проекты

+
+ ${projects} + +
+
+ `, + }), +}; + +export const MyProjectsAfter: Story = { + name: "Мои проекты · после", + render: () => ({ + props: { count }, + template: ` + ${fixtureStyles} +
+

Проекты · мои проекты

+
+ ${projects} + +
+
+ `, + }), +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.ts new file mode 100644 index 000000000..7b0df80a6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; +import { ProjectCount, ProjectCountLoadState } from "@domain/project/project.model"; +import { IconComponent } from "@ui/primitives/icon/icon.component"; + +interface ProjectActivityMetric { + icon: string; + label: string; + value: number; +} + +/** Компактная информационная карточка проектной активности текущего пользователя. */ +@Component({ + selector: "app-project-activity-card", + templateUrl: "./project-activity-card.component.html", + styleUrl: "./project-activity-card.component.scss", + imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectActivityCardComponent { + readonly count = input.required(); + readonly state = input.required(); + + protected readonly metrics = computed(() => [ + { icon: "projects", label: "Проектов", value: this.count().my }, + { icon: "person", label: "Я лидер", value: this.count().myLeader }, + { icon: "program", label: "В программе", value: this.count().myInProgram }, + { icon: "check", label: "Сдано", value: this.count().mySubmitted }, + ]); + + protected readonly isLoaded = computed(() => this.state() === "loaded"); + protected readonly placeholderLabel = computed(() => + this.state() === "error" ? "Данные временно недоступны" : "Загрузка данных", + ); +} diff --git a/projects/social_platform/src/app/ui/pages/projects/projects-activity-placement.spec.ts b/projects/social_platform/src/app/ui/pages/projects/projects-activity-placement.spec.ts new file mode 100644 index 000000000..a2ede1797 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects-activity-placement.spec.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +describe("ProjectsComponent: размещение проектной активности", () => { + const template = readFileSync( + join( + process.cwd(), + "projects/social_platform/src/app/ui/pages/projects/projects.component.html", + ), + "utf8", + ); + + it("показывает блок только для dashboard и my", () => { + expect(template).toMatch( + /@if \(isMy\(\) \|\| isDashboard\(\)\) \{\s* { + const conditionalBlock = new RegExp( + `@if \\(${condition.replace(/[()]/g, "\\$&")}\\) \\{[\\s\\S]*? { + let section: string; + const office = { onAcceptInvite: vi.fn(), onRejectInvite: vi.fn() }; + + beforeEach(async () => { + section = "dashboard"; + vi.clearAllMocks(); + await TestBed.configureTestingModule({ + imports: [ProjectsComponent], + providers: [ + provideRouter([]), + { provide: OfficeInfoService, useValue: office }, + { + provide: IndustryRepositoryPort, + useValue: { industries: signal([{ id: 1, name: "Образование" }]) }, + }, + { provide: AddProjectSubscriptionUseCase, useValue: { execute: () => of(ok(undefined)) } }, + { + provide: DeleteProjectSubscriptionUseCase, + useValue: { execute: () => of(ok(undefined)) }, + }, + ], + }) + .overrideComponent(ProjectsComponent, { + set: { + providers: [ + { + provide: ProjectsInfoService, + useValue: { + isDashboard: () => section === "dashboard", + isMy: () => section === "my", + isSubs: () => section === "subscriptions", + isInvites: () => section === "invites", + isAll: () => section === "all", + projectCount: signal({ my: 26, myLeader: 23, myInProgram: 1, mySubmitted: 4 }), + projectCountState: signal("loaded"), + initializationProjects: vi.fn(), + }, + }, + { + provide: ProjectsUIInfoService, + useValue: { + searchForm: new FormGroup({ search: new FormControl("") }), + myInvites: signal([ + projectCardFixture({ inviteId: 801, industry: 1, name: "Проект приглашения" }), + ]), + }, + }, + { provide: SwipeService, useValue: { isFilterOpen: signal(false) } }, + ], + }, + }) + .compileComponents(); + }); + + afterEach(async () => { + // Завершаем отложенное прикрепление portal общей модалки до уничтожения TestBed. + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it.each(["dashboard", "my", "subscriptions", "invites", "all"])( + "сохраняет размещение активности в разделе %s", + current => { + section = current; + const fixture = TestBed.createComponent(ProjectsComponent); + fixture.detectChanges(); + const activity = fixture.nativeElement.querySelector("app-project-activity-card"); + if (current === "dashboard" || current === "my") { + expect(activity.querySelector("h2").textContent.trim()).toBe("моя активность"); + expect( + Array.from(activity.querySelectorAll(".activity__value"), (node: unknown) => + (node as HTMLElement).textContent?.trim(), + ), + ).toEqual(["26", "23", "1", "4"]); + } else { + expect(activity).toBeNull(); + } + }, + ); + + it("приглашение справа не содержит отрасль и сохраняет оба действия", () => { + const fixture = TestBed.createComponent(ProjectsComponent); + fixture.detectChanges(); + const invitation: HTMLElement = fixture.nativeElement.querySelector( + ".page__invites app-info-card", + ); + expect(invitation.textContent).not.toContain("Образование"); + expect(invitation.querySelector(".card__industry, .card__context--industry")).toBeNull(); + expect(invitation.querySelector(".card__name")?.textContent).toContain("Проект приглашения"); + const buttons = Array.from(invitation.querySelectorAll("button")); + buttons.find(button => button.textContent?.trim() === "принять")!.click(); + buttons.find(button => button.textContent?.trim() === "отклонить")!.click(); + expect(office.onAcceptInvite).toHaveBeenCalledExactlyOnceWith(801); + expect(office.onRejectInvite).toHaveBeenCalledExactlyOnceWith(801); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.html b/projects/social_platform/src/app/ui/pages/projects/projects.component.html index 1fcc04c36..16b3d3aa2 100644 --- a/projects/social_platform/src/app/ui/pages/projects/projects.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.html @@ -89,11 +89,10 @@ } @if (isMy() || isDashboard()) { - - + } @if (isAll()) {
diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.ts b/projects/social_platform/src/app/ui/pages/projects/projects.component.ts index 3edf5631e..246002a8d 100644 --- a/projects/social_platform/src/app/ui/pages/projects/projects.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.ts @@ -12,16 +12,17 @@ import { import { RouterOutlet } from "@angular/router"; import { ReactiveFormsModule } from "@angular/forms"; import { SearchComponent } from "@ui/primitives/search/search.component"; -import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { ButtonComponent } from "@ui/primitives/button/button.component"; +import { IconComponent } from "@ui/primitives/icon/icon.component"; import { BarNewComponent } from "./bar-new/bar.component"; import { BackComponent } from "@uilib"; -import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; import { ProjectsUIInfoService } from "@api/project/facades/ui/projects-ui-info.service"; import { ProjectsInfoService } from "@api/project/facades/projects-info.service"; import { SwipeService } from "@api/swipe/swipe.service"; import { ProjectsFilterComponent } from "@ui/widgets/projects-filter/projects-filter.component"; import { OfficeInfoService } from "@api/office/facades/office-info.service"; +import { ProjectActivityCardComponent } from "./project-activity-card/project-activity-card.component"; /** Контейнер модуля проектов с поиском, фильтрацией и навигацией по разделам. */ @Component({ @@ -36,7 +37,7 @@ import { OfficeInfoService } from "@api/office/facades/office-info.service"; RouterOutlet, BarNewComponent, BackComponent, - SoonCardComponent, + ProjectActivityCardComponent, ProjectsFilterComponent, InfoCardComponent, ], @@ -64,6 +65,8 @@ export class ProjectsComponent implements OnInit { protected readonly isSubs = this.projectsInfoService.isSubs; protected readonly isInvites = this.projectsInfoService.isInvites; protected readonly isDashboard = this.projectsInfoService.isDashboard; + protected readonly projectCount = this.projectsInfoService.projectCount; + protected readonly projectCountState = this.projectsInfoService.projectCountState; protected readonly isFilterOpen = this.swipeService.isFilterOpen; diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html index 6f87e5597..b07e47fbe 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html @@ -40,7 +40,6 @@ class="card__project-link" [routerLink]="AppRoutes.projects.detail(info()?.id)" [attr.aria-label]="'Открыть проект «' + info()?.name + '»'" - [attr.title]="projectIndustry()" > @@ -60,10 +59,6 @@

{{ presentation.statusLabel }}

- } @else if (projectIndustry(); as industry) { -

- {{ industry }} -

}
@@ -118,13 +113,8 @@

{{ info().name }}

} @else if (type() === "projects" || type() === "invite") { @if (info().name) { -

{{ info().name | truncate: 12 }}

+

{{ info().name }}

} -

- @if (industryRepository.getOne(info()?.industry!); as industry) { - {{ industry?.name }} - } -

} @else {

{{ info()?.firstName | truncate: 10 }}

{{ info()?.lastName | truncate: 10 }}

@@ -147,7 +137,7 @@ class="card__description" [class.card__description--project]="type() === 'projects'" [style.max-height]=" - isProjectCard() ? '26px' : type() === 'invite' ? null : type() === 'members' ? '50px' : '24px' + isProjectCard() || type() === 'invite' ? null : type() === 'members' ? '50px' : '24px' " [class.invite-description]="type() === 'invite'" > diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss index 023ef10fb..554b9eb0b 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss @@ -139,13 +139,6 @@ color: var(--accent); } - &__industry { - padding: 2px 36px; - color: var(--light-white); - background-color: var(--green); - border-radius: var(--rounded-xl); - } - &__description { display: flex; flex: 1; @@ -286,18 +279,19 @@ } .card__description { - flex: 0 0 26px; + // Место удалённой отрасли отдано описанию без увеличения внешней карточки. + flex: 0 0 52px; width: 100%; - height: 26px; - min-height: 26px; + height: 52px; + min-height: 52px; p { - /* stylelint-disable-next-line value-no-vendor-prefix -- Префикс сохраняет двухстрочное сокращение описания. */ + /* stylelint-disable-next-line value-no-vendor-prefix -- Префикс ограничивает описание четырьмя строками. */ display: -webkit-box; overflow: hidden; font-size: 10px; line-height: 13px; - -webkit-line-clamp: 2; + -webkit-line-clamp: 4; -webkit-box-orient: vertical; } } @@ -350,6 +344,7 @@ p { font-size: 9px; line-height: 12px; + -webkit-line-clamp: 2; } } @@ -360,7 +355,7 @@ width: 100%; } - // Увеличение публичных CTA не меняет типографику кнопки «Моих проектов». + // Типографика публичных CTA в master не зависит от строки отрасли. .card__project-action-label { font-size: 9px; line-height: 11px; @@ -387,16 +382,16 @@ } } - .card__industry { - width: 100%; - padding: 0 8px; + .card__name { + /* stylelint-disable-next-line value-no-vendor-prefix -- Освободившаяся строка сохраняет длинное название. */ + display: -webkit-box; + max-height: 30px; overflow: hidden; - font-size: 11px; - font-weight: 400; - line-height: 14px; - color: var(--black); - text-overflow: ellipsis; - white-space: nowrap; + line-height: 15px; + overflow-wrap: anywhere; + white-space: normal; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } .card__description { @@ -472,23 +467,6 @@ white-space: nowrap; background: var(--light-gray); border-radius: 11px; - - &--industry { - font-size: 11px; - font-weight: 400; - color: var(--accent-dark); - background: var(--accent-light); - - span { - overflow: hidden; - text-overflow: ellipsis; - } - - &:focus-visible { - outline: 2px solid var(--accent-dark); - outline-offset: 2px; - } - } } &__status { diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts index 321a713dc..c08c35bd1 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts @@ -233,7 +233,7 @@ describe("InfoCardComponent: статусы моих проектов", () => { }); it.each(["base", "subs"] as const)( - "%s: отрасль, полный текст, Открыть и отсутствие lifecycle/шума", + "%s: полный текст и Открыть без отрасли и lifecycle", appearance => { const project = projectCardFixture({ draft: true, partnerProgram: projectCardProgram(true) }); fixture.componentRef.setInput("info", project); @@ -243,9 +243,8 @@ describe("InfoCardComponent: статусы моих проектов", () => { expect(card().querySelector(".card__access")).toBeNull(); expect(fixture.nativeElement.querySelector(".card--project")).not.toBeNull(); expect(card().querySelector(".card__name")?.textContent).toBe(project.name); - expect(card().querySelector(".card__context--industry")?.textContent?.trim()).toBe( - "Образование", - ); + expect(card().querySelector(".card__context--industry")).toBeNull(); + expect(card().textContent).not.toContain("Образование"); expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe( "Открыть", ); @@ -268,17 +267,33 @@ describe("InfoCardComponent: статусы моих проектов", () => { expect(card().querySelector(".card__name")?.textContent).toBe(projectCardFixture().name); }); - it("длинная отрасль доступна целиком в подсказке и с клавиатуры", () => { + it.each(["base", "subs"] as const)("%s: отрасль не остаётся в скрытой подсказке", appearance => { const name = "Очень длинное название отрасли проекта"; - vi.spyOn(TestBed.inject(IndustryRepositoryPort), "getOne").mockReturnValue({ id: 1, name }); - fixture.componentRef.setInput("appereance", "subs"); + const getIndustry = vi.spyOn(TestBed.inject(IndustryRepositoryPort), "getOne"); + getIndustry.mockReturnValue({ id: 1, name }); + fixture.componentRef.setInput("appereance", appearance); fixture.detectChanges(); - const context = card().querySelector(".card__context--industry")!; - expect(context.title).toBe(name); - expect(context.textContent?.trim()).toBe(name); + expect(card().querySelector(".card__context--industry, .card__industry")).toBeNull(); + expect(card().textContent).not.toContain(name); const link = card().querySelector(".card__project-link")!; - expect(link.title).toBe(name); + expect(link.hasAttribute("title")).toBe(false); expect(link.tabIndex).toBe(0); + expect(getIndustry).not.toHaveBeenCalled(); + }); + + it("приглашение сохраняет название, действия и данные проекта без отрасли", () => { + const project = projectCardFixture({ + name: "Длинное название приглашённого проекта", + inviteId: 801, + }); + fixture.componentRef.setInput("type", "invite"); + fixture.componentRef.setInput("info", project); + fixture.detectChanges(); + expect(card().querySelector(".card__industry, .card__context--industry")).toBeNull(); + expect(card().textContent).not.toContain("Образование"); + expect(card().querySelector(".card__name")?.textContent).toBe(project.name); + expect(card().querySelectorAll(".card__invite-actions button")).toHaveLength(2); + expect(project.industry).toBe(projectCardFixture().industry); }); it.each(["base", "subs"] as const)( diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts index 03470a66b..1a4b946e3 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts @@ -24,7 +24,6 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; 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 { AppRoutes } from "@api/paths/app-routes"; -import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; import { Project } from "@domain/project/project.model"; interface MyProjectPresentation { @@ -62,7 +61,6 @@ export class InfoCardComponent { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly addProjectSubscriptionUseCase = inject(AddProjectSubscriptionUseCase); private readonly deleteProjectSubscriptionUseCase = inject(DeleteProjectSubscriptionUseCase); - public readonly industryRepository = inject(IndustryRepositoryPort); private readonly router = inject(Router); private readonly logger = inject(LoggerService); @@ -84,15 +82,6 @@ export class InfoCardComponent { () => this.type() === "projects" && this.appereance() !== "empty", ); - /** Отрасль берётся из уже загруженного справочника; пустая плашка не занимает строку. */ - protected readonly projectIndustry = computed(() => { - if (!this.isProjectCard() || this.appereance() === "my") return null; - const industryId = this.info()?.industry; - return industryId == null - ? null - : this.industryRepository.getOne(industryId)?.name?.trim() || null; - }); - /** * Lifecycle зависит только от проекта: submitted > draft > program > published. * Роль определяется отдельно по текущему профилю. Отсутствующие ID не делают diff --git a/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.spec.ts b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.spec.ts index d62228af1..932edeabf 100644 --- a/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.spec.ts @@ -3,10 +3,11 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ProjectsFilterComponent } from "./projects-filter.component"; -import { provideRouter } from "@angular/router"; +import { provideRouter, Router } from "@angular/router"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; import { signal } from "@angular/core"; +import { ProjectsFilterInfoService } from "./service/projects-filter-info.service"; describe("ProjectsFilterComponent", () => { let component: ProjectsFilterComponent; @@ -14,7 +15,7 @@ describe("ProjectsFilterComponent", () => { beforeEach(async () => { const industrySpy = { - industries: signal([]), + industries: signal([{ id: 5, name: "EdTech" }]), }; await TestBed.configureTestingModule({ @@ -32,4 +33,20 @@ describe("ProjectsFilterComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("сохраняет справочник отраслей и фильтрацию через query-параметр industry", async () => { + await fixture.whenStable(); + const service = fixture.debugElement.injector.get(ProjectsFilterInfoService); + expect(service.industries()).toEqual([{ id: 5, label: "EdTech", value: "EdTech" }]); + const navigate = vi.spyOn(TestBed.inject(Router), "navigate").mockResolvedValue(true); + component.onFilterByIndustry(5); + expect(navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: { industry: 5 }, + queryParamsHandling: "merge", + }), + ); + vi.restoreAllMocks(); + }); });