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
17 changes: 17 additions & 0 deletions docs/project-cards-activity/README-prod.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- @format -->

# Проверка переноса блока проектов в 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`.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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,
});
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -39,8 +40,19 @@ describe("ProjectRepository", () => {
});
eventBus = TestBed.inject(EventBus);
repository = TestBed.inject(ProjectRepository);
adapter.fetchCount.mockReturnValue(of(activityCount()));
}

const activityCount = (overrides: Partial<ProjectCount> = {}): ProjectCount => ({
all: 0,
my: 0,
subs: 0,
myLeader: 0,
myInProgram: 0,
mySubmitted: 0,
...overrides,
});

const page = (): ApiPagination<ProjectDto> => ({
count: 1,
next: "",
Expand Down Expand Up @@ -97,17 +109,41 @@ describe("ProjectRepository", () => {
it("refreshCount мапит в ProjectCount и пушит в count$", () =>
new Promise<void>(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<void>(done => {
setup();
Expand Down Expand Up @@ -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));

Expand All @@ -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));
Expand Down
Loading