Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/project-activity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!-- @format -->

# Блок «Моя активность» в проектах

На 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.
Binary file added docs/project-activity/dashboard-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/project-activity/dashboard-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/project-activity/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/project-activity/my-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/project-activity/my-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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<NavigationEnd>();
const router = {
url: initialUrl,
events,
navigate: vi.fn(() => Promise.resolve(true)),
};
const repository = {
count$: new BehaviorSubject(count),
countState$: new BehaviorSubject<ProjectCountLoadState>("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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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;
Expand All @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/** @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";

/** Порт репозитория проектов — контракт CRUD. Реализуется в infrastructure/repository/project. */
export abstract class ProjectRepositoryPort {
abstract readonly count$: BehaviorSubject<ProjectCount>;
abstract readonly countState$: BehaviorSubject<ProjectCountLoadState>;

abstract getAll(params?: HttpParams): Observable<ApiPagination<Project>>;
abstract getOne(id: number): Observable<Project>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export interface ProjectCountDto {
all: number;
my: number;
subs: number;
myLeader: number;
myInProgram: number;
mySubmitted: number;
}

export interface ProjectListDto {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading