diff --git a/docs/project-activity/README.md b/docs/project-activity/README.md new file mode 100644 index 000000000..7a7dec394 --- /dev/null +++ b/docs/project-activity/README.md @@ -0,0 +1,73 @@ + + +# Блок «Моя активность» в проектах + +На dashboard проектов и в разделе «Мои проекты» прежняя заглушка +`app-soon-card` заменена отдельным информационным компонентом +`ProjectActivityCardComponent`. Общий `SoonCardComponent` не изменён. + +Источник данных — существующий `ProjectRepositoryPort.refreshCount()` и +`count$`, которые получают authoritative ответ `GET /projects/count/`. +Компонент не выполняет HTTP-запросы и не вычисляет статистику по первым +16 проектам dashboard. + +Показатели: + +- «Проектов» — все уникальные legacy-проекты пользователя; +- «Я лидер» — проекты, где пользователь является лидером; +- «В программе» — опубликованные проекты с канонической, ещё не сданной + связью `PartnerProgramProject`; +- «Сдано» — проекты, у которых каноническая связь с программой сдана. + +Каноническая связь определяется backend по минимальному `pk`. После +успешного принятия приглашения Angular повторно запрашивает весь счётчик, +не пытаясь оптимистично вычислять lifecycle. + +Во время загрузки и при ошибке вместо недостоверных чисел отображается +нейтральный прочерк с доступной screen reader подписью. Для пользователя без +проектов отображаются четыре нуля. + +## Геометрия + +| Элемент | До | После | +| --------------------------- | ----------------: | ----------------: | +| Ширина карточки | 157 px | 157 px | +| Высота карточки | 140,2 px | 141 px | +| X правой колонки | 1075,8 px | 1075,8 px | +| Y карточки | 203,8 px | 203,8 px | +| Положение блока приглашений | 1075,8 × 113,8 px | 1075,8 × 113,8 px | + +Замеры сделаны в desktop Storybook-истории на реальных Angular-компонентах. +Разница высоты менее одного пикселя не меняет положение карточки, блока +приглашений или ширину правой колонки. + +## Визуальная проверка + +### Dashboard + +До: + +![Dashboard до](./dashboard-before.png) + +После: + +![Dashboard после](./dashboard-after.png) + +### Мои проекты + +До: + +![Мои проекты до](./my-before.png) + +После: + +![Мои проекты после](./my-after.png) + +### Mobile + +![Mobile](./mobile.png) + +Снимки созданы из Storybook-историй с реальными `SoonCardComponent` и +`ProjectActivityCardComponent`. Они фиксируют desktop и mobile footprint; +данные в истории демонстрационные, production-значения приходят только из +backend count endpoint. diff --git a/docs/project-activity/dashboard-after.png b/docs/project-activity/dashboard-after.png new file mode 100644 index 000000000..a497dd940 Binary files /dev/null and b/docs/project-activity/dashboard-after.png differ diff --git a/docs/project-activity/dashboard-before.png b/docs/project-activity/dashboard-before.png new file mode 100644 index 000000000..02df4d6be Binary files /dev/null and b/docs/project-activity/dashboard-before.png differ diff --git a/docs/project-activity/mobile.png b/docs/project-activity/mobile.png new file mode 100644 index 000000000..6677b5179 Binary files /dev/null and b/docs/project-activity/mobile.png differ diff --git a/docs/project-activity/my-after.png b/docs/project-activity/my-after.png new file mode 100644 index 000000000..d79508ae9 Binary files /dev/null and b/docs/project-activity/my-after.png differ diff --git a/docs/project-activity/my-before.png b/docs/project-activity/my-before.png new file mode 100644 index 000000000..c67758a24 Binary files /dev/null and b/docs/project-activity/my-before.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/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..fb532db80 --- /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..c8a41b967 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.scss @@ -0,0 +1,71 @@ +:host { + display: block; + width: 157px; + height: 141px; +} + +.activity { + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 9px 10px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__title { + margin: 0 0 5px; + font-size: 11px; + font-weight: 600; + line-height: 14px; + color: var(--accent); + text-transform: uppercase; + } + + &__metrics { + margin: 0; + } + + &__metric { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 26px; + + & + & { + border-top: 0.5px solid var(--medium-grey-for-outline); + } + } + + &__label { + display: flex; + gap: 4px; + align-items: center; + min-width: 0; + font-size: 9px; + font-weight: 400; + line-height: 12px; + color: var(--black); + white-space: nowrap; + } + + &__icon { + display: grid; + flex: 0 0 20px; + place-items: center; + width: 20px; + height: 20px; + color: var(--accent); + background-color: var(--accent-light); + border-radius: 50%; + } + + &__value { + flex: 0 0 auto; + margin: 0 0 0 4px; + font-size: 13px; + font-weight: 600; + line-height: 16px; + color: var(--accent); + } +} 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..434569b54 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts @@ -0,0 +1,58 @@ +/** @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("Подробнее"); + }); + + 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(["—", "—", "—", "—"]); + }); +}); 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]*? } @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;