diff --git a/docs/prod-program-analytics-project-editor.md b/docs/prod-program-analytics-project-editor.md new file mode 100644 index 000000000..9698c0b2f --- /dev/null +++ b/docs/prod-program-analytics-project-editor.md @@ -0,0 +1,76 @@ + + +# PROD: аналитика программы и редактор проекта + +Выборочный перенос проверенных DEV PR #348, #349, #350, #351 на PROD master +`72b50306cbd7c7fc33e2efad66052c43759b33db`. Слияния dev → master нет. + +## Область изменений + +- Ролевой виджет программы: организатор → эксперт → участник. Backend + `GET /programs/{id}/analytics-widget/` определяет права и отдаёт одну ветвь + метрик. Ошибки сохраняются как ошибки, не становятся нулевыми счётчиками. +- Новости программы загружаются и показываются организатору без member-роли. + Экспертная роль сама по себе не открывает новости. +- Очистка файлов использует инициализированный `Project.id`, а не корневой + ActivatedRoute. Невалидные ID и старые повреждённые записи offline queue + отфильтровываются до запроса. Начальное пустое поле не вызывает PUT. +- Явное удаление presentation сохраняет пустую строку. DELETE 404 идемпотентен; + network/4xx/5xx сохраняют control и показывают ошибку. Перед публикацией + очистка подтверждается отдельным сохранением черновика. +- Project-specific `POST /projects/{id}/reset-cover/` и canonical + `isDefaultCover` исключают DELETE системной обложки. Поле остаётся required. +- Стандартная/пустая обложка показывает «Стандартная обложка» и «Загрузить файл», + пользовательская — «Заменить файл». Верхней иконки сброса нет. + Размеры и соседнее расположение элементов сохранены. + +## Совместимость PROD + +Существующие `currentProjectApplication` и URL основной legacy-аналитики +`project-analytics` сохранены. Виджет не использует это leader-only поле +для поиска проекта члена команды; его `participantProject` приходит отдельно. + +PROD hotfix уведомлений, вакансий, регионов и другие отличия master остаются. +Shared app-modal и app-soon-card, зависимости, CI/workflows не изменяются. +React и его доменные контракты не затрагиваются. + +Backend переносится первым: #742 (read-only виджет и is_user_expert) и #743 +(reset-cover и is_default_cover). Миграций задачи нет. Старый frontend +совместим с новыми additive endpoints. + +## Источники переноса + +| DEV PR | Merge commit | +| ------ | ------------------------------------------ | +| #348 | `8f59c8ed7bbad1ceb1deba669c65b66a92040231` | +| #349 | `c0bbdd7a97f2575864e3d4d8c7d88e927b61bd47` | +| #350 | `2324f52345fe608991e888a514aefc491e578bcb` | +| #351 | `c66d05db59b6dbae160c0f3fc8853a9fa6644377` | + +Большие DEV-only screenshots/diagnostics не включены в PROD diff. Они доступны +в исходных PR. Существующая документация master не удалялась. + +## Проверки + +Локальный прогон 18.09.2026, Node 20.20.2, отдельная ветка от PROD master: + +- `npm ci` — успешно, lockfile не менялся. +- Targeted Vitest (17 файлов, `--pool=forks --maxWorkers=4 --minWorkers=1`) — + 216/216, включая виджет, manager news, autosave, форму, edit info, UploadFile, + reset-cover и zero state. +- `npm run format:check`, `npm run lint:ts` (0 ошибок, 6 предупреждений), + scoped Stylelint, `npm run build:prod`, `git diff --check` — успешно. +- `npm run test:ci -- --pool=forks --maxWorkers=4 --minWorkers=1` — + **exit 1**: 377 файлов и 1728 тестов прошли, но возникла необработанная ошибка + `ReferenceError: window is not defined` из таймера `ngx-autosize` после teardown + `profile-mid-side.component.spec.ts`. Этот прогон не считается успешным. + +Стандартный локальный pool ранее завершился на NG0401 до выполнения targeted +тестов. Forks меняет только способ изоляции процессов; тесты не исключались, +Vitest config и setup не изменялись. Ошибка полного прогона не подавлена. + +PROD-релиз остановлен до snapshot/merge/deploy по условию задачи. Для продолжения +нужен успешный новый полный прогон; старые результаты DEV не заменяют этот gate. + +Без отдельного production test account/project нельзя выполнять +авторизованную изменяющую данные приёмку на реальных проектах. diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts index 5455debde..489db28a9 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts @@ -95,8 +95,8 @@ export class ProgramDetailMainService { this.programDetailMainUIInfoService.applyFormatingProgramData(program); }), concatMap(program => { - // Новости программы доступны только участникам программы. - if (program.isUserMember) { + // Видимость списка и загрузка совпадают; эксперт сам по себе доступ не получает. + if (program.isUserMember || program.isUserManager) { return this.fetchNews(0, this.fetchLimit()); } else { return of( diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.spec.ts new file mode 100644 index 000000000..1693072ba --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.spec.ts @@ -0,0 +1,131 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { signal } from "@angular/core"; +import { ActivatedRoute, convertToParamMap } from "@angular/router"; +import { BehaviorSubject, Subject } from "rxjs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; +import { GetProgramRoleWidgetUseCase } from "../../use-cases/get-program-role-widget.use-case"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; +import { ProgramRoleWidgetService } from "./program-role-widget.service"; +import { Program } from "@domain/program/program.model"; +import { ProgramRoleWidget, ProgramWidgetError } from "@domain/program/program-role-widget.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; + +describe("ProgramRoleWidgetService", () => { + let service: ProgramRoleWidgetService; + const profile = signal<{ id: number } | null>({ id: 7 }); + const program = signal(Object.assign(Program.default(), { id: 12, isUserMember: true })); + let params: BehaviorSubject>; + let responses: Subject>[]; + const result = (id = 12): ProgramRoleWidget => ({ + programId: id, + isCompetitive: true, + role: "participant", + participant: { + participantProject: null, + caseProvided: false, + caseName: null, + stage: "none", + submissionOpen: true, + }, + }); + beforeEach(() => { + profile.set({ id: 7 }); + program.set(Object.assign(Program.default(), { id: 12, isUserMember: true })); + params = new BehaviorSubject(convertToParamMap({ programId: 12 })); + responses = []; + TestBed.configureTestingModule({ + providers: [ + ProgramRoleWidgetService, + { provide: AuthInfoService, useValue: { profile } }, + { provide: ProgramDetailMainUIInfoService, useValue: { program } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: params.value }, paramMap: params }, + }, + { + provide: GetProgramRoleWidgetUseCase, + useValue: { + execute: vi.fn(() => { + const response = new Subject>(); + responses.push(response); + return response; + }), + }, + }, + ], + }); + service = TestBed.inject(ProgramRoleWidgetService); + TestBed.flushEffects(); + }); + it("starts loading and returns server data", () => { + expect(service.state().status).toBe("loading"); + responses[0].next(ok(result())); + expect(service.state()).toEqual({ status: "success", data: result() }); + }); + it("does not replace a failure with zero and permits explicit retry", () => { + responses[0].next(fail("network")); + expect(service.state()).toEqual({ status: "failure", error: "network", previous: undefined }); + service.retry(); + expect(responses).toHaveLength(2); + responses[1].next(ok(result())); + expect(service.state().status).toBe("success"); + }); + it.each(["forbidden", "unauthorized", "not_found", "integrity"] as const)( + "does not retry %s", + error => { + responses[0].next(fail(error)); + service.retry(); + expect(responses).toHaveLength(1); + }, + ); + it("clears on route changes before the resolver and ignores late responses", () => { + responses[0].next(ok(result())); + params.next(convertToParamMap({ programId: 13 })); + expect(service.state().status).toBe("loading"); + TestBed.flushEffects(); + responses[0].next(ok(result())); + expect(service.state().status).toBe("loading"); + program.set(Object.assign(new Program(), program(), { id: 13 })); + TestBed.flushEffects(); + responses[1].next(ok(result(13))); + expect(service.state()).toMatchObject({ status: "success", data: { programId: 13 } }); + }); + it("clears immediately on user and role changes", () => { + responses[0].next(ok(result())); + profile.set({ id: 8 }); + expect(service.state().status).toBe("loading"); + TestBed.flushEffects(); + responses[0].next(ok(result())); + expect(service.state().status).toBe("loading"); + program.set(Object.assign(new Program(), program(), { isUserExpert: true })); + TestBed.flushEffects(); + expect(responses).toHaveLength(3); + }); + it("refreshes on successful domain changes and cancels on logout", () => { + responses[0].next(ok(result())); + const events = TestBed.inject(EventBus); + events.emit({ type: "ProgramWidgetChanged", payload: {}, occurredAt: new Date() }); + expect(responses).toHaveLength(2); + events.emit({ type: "LoggedOut", payload: {}, occurredAt: new Date() }); + responses[1].next(ok(result())); + expect(service.state()).toMatchObject({ status: "failure", error: "unauthorized" }); + profile.set({ id: 8 }); + TestBed.flushEffects(); + expect(responses).toHaveLength(3); + }); + it("does not request data for an unrelated user", () => { + program.set(Object.assign(Program.default(), { id: 12 })); + TestBed.flushEffects(); + expect(responses).toHaveLength(1); + responses[0].next(ok(result())); + expect(service.state()).toMatchObject({ status: "failure", error: "forbidden" }); + }); + it("rejects mismatched program data", () => { + responses[0].next(ok(result(14))); + expect(service.state()).toMatchObject({ status: "failure", error: "not_found" }); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.ts new file mode 100644 index 000000000..362dca7d1 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.ts @@ -0,0 +1,98 @@ +/** @format */ + +import { computed, DestroyRef, effect, inject, Injectable, signal, untracked } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; +import { GetProgramRoleWidgetUseCase } from "../../use-cases/get-program-role-widget.use-case"; +import { programWidgetRole } from "../../program-widget-presentation"; +import { ProgramRoleWidget, ProgramWidgetError } from "@domain/program/program-role-widget.model"; +import { AsyncState, failure, loading, success } from "@domain/shared/async-state"; +import { EventBus } from "@domain/shared/event-bus"; +import { Subscription } from "rxjs"; +import { User } from "@domain/auth/user.model"; + +/** Локальный запрос без кэша/polling. Старый ответ не переживает смену контекста или logout. */ +@Injectable() +export class ProgramRoleWidgetService { + private readonly auth = inject(AuthInfoService); + private readonly programUI = inject(ProgramDetailMainUIInfoService); + private readonly request = inject(GetProgramRoleWidgetUseCase); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly events = inject(EventBus); + private readonly routeId = signal(Number(this.route.snapshot.paramMap.get("programId"))); + private readonly rawState = signal>(loading()); + private readonly loadedKey = signal(""); + private readonly loggedOutProfile = signal(undefined); + private activeRequest?: Subscription; + private version = 0; + private readonly key = computed(() => { + const program = this.programUI.program(); + const profile = this.auth.profile(); + const userId = profile !== this.loggedOutProfile() ? profile?.id : null; + const role = programWidgetRole(program); + return userId && role && program?.id === this.routeId() + ? `${program.id}:${userId}:${role}` + : ""; + }); + readonly state = computed>(() => { + if (!this.auth.profile()?.id || this.auth.profile() === this.loggedOutProfile()) + return failure("unauthorized"); + const program = this.programUI.program(); + if (program && !programWidgetRole(program)) return failure("forbidden"); + return this.key() && this.loadedKey() === this.key() + ? this.rawState() + : loading(); + }); + readonly programId = computed(() => this.programUI.program()?.id ?? 0); + + constructor() { + this.route.paramMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(params => this.routeId.set(Number(params.get("programId")))); + effect(() => { + const key = this.key(); + untracked(() => this.load(key)); + }); + this.events + .on("LoggedOut") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.loggedOutProfile.set(this.auth.profile()); + this.load(""); + }); + this.events + .on("ProgramWidgetChanged") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.load(this.key())); + this.destroyRef.onDestroy(() => { + this.version++; + this.activeRequest?.unsubscribe(); + }); + } + /** Повтор разрешён только для сетевого сбоя, без обхода отказа в доступе. */ + retry(): void { + const state = this.state(); + if (state.status === "failure" && state.error === "network") this.load(this.key()); + } + private load(key: string): void { + const version = ++this.version; + this.activeRequest?.unsubscribe(); + this.loadedKey.set(key); + this.rawState.set(loading()); + if (!key) return; + const programId = this.programId(); + this.activeRequest = this.request.execute(programId).subscribe(result => { + if (version !== this.version || key !== this.key()) return; + this.rawState.set( + result.ok + ? result.value.programId === programId + ? success(result.value) + : failure("not_found") + : failure(result.error), + ); + }); + } +} diff --git a/projects/social_platform/src/app/api/program/program-widget-presentation.spec.ts b/projects/social_platform/src/app/api/program/program-widget-presentation.spec.ts new file mode 100644 index 000000000..03f5fb9ca --- /dev/null +++ b/projects/social_platform/src/app/api/program/program-widget-presentation.spec.ts @@ -0,0 +1,141 @@ +/** @format */ + +import { describe, expect, it } from "vitest"; +import { + expertWidgetPresentation, + participantSteps, + programWidgetRole, + withoutProjectPresentation, +} from "./program-widget-presentation"; +import { Program } from "@domain/program/program.model"; +import { ExpertWidget } from "@domain/program/program-role-widget.model"; + +describe("Program widget presentation", () => { + it("uses only program roles with organizer/expert/member priority", () => { + const program = Program.default(); + expect(programWidgetRole(program)).toBeNull(); + program.isUserMember = true; + expect(programWidgetRole(program)).toBe("participant"); + program.isUserExpert = true; + expect(programWidgetRole(program)).toBe("expert"); + program.isUserMember = false; + expect(programWidgetRole(program)).toBe("expert"); + program.isUserManager = true; + expect(programWidgetRole(program)).toBe("organizer"); + }); + it.each([ + [0, 0, "—", "neutral"], + [0, 100, "0,0%", "green"], + [10, 100, "10,0%", "green"], + [1001, 10000, "10,0%", "yellow"], + [25, 100, "25,0%", "yellow"], + [2501, 10000, "25,0%", "red"], + [19, 248, "7,7%", "green"], + ])("formats %i/%i before rounding for colour", (count, total, percentage, tone) => { + expect( + withoutProjectPresentation({ + participants: Number(total), + participantsWithoutProject: Number(count), + projects: 0, + submittedSolutions: 0, + }), + ).toEqual({ percentage, tone, description: `${count} из ${total} участников без проекта` }); + }); + const now = Date.parse("2026-09-01T12:00:00Z"); + const expert: ExpertWidget = { + mode: "distributed", + assigned: 7, + remaining: 3, + evaluationEnds: null, + }; + it.each([ + [49 * 3600000, "green", "3 дня"], + [48 * 3600000, "yellow", "2 дня"], + [23 * 3600000, "yellow", "23 ч"], + [59 * 60000, "yellow", "59 мин"], + [1, "yellow", "1 мин"], + [0, "red", "Срок оценивания истёк"], + [-1, "red", "Срок оценивания истёк"], + ])("handles deadline distance %i", (delta, tone, text) => { + const result = expertWidgetPresentation( + { ...expert, evaluationEnds: new Date(now + Number(delta)).toISOString() }, + true, + now, + ); + expect(result.tone).toBe(tone); + expect(result.text).toContain(text); + }); + it("uses absolute instants across timezones", () => + expect( + expertWidgetPresentation( + { ...expert, evaluationEnds: "2026-09-01T15:00:00+03:00" }, + true, + now, + ).tone, + ).toBe("red")); + it("keeps missing deadline neutral", () => + expect(expertWidgetPresentation(expert, true, now).text).toBe("Срок не установлен")); + it.each([ + [25, "25 минут"], + [21, "21 минута"], + [22, "22 минуты"], + [11, "11 минут"], + [300, "5 часов"], + ])("keeps the full duration and unit for %i minutes", (minutes, value) => { + const result = expertWidgetPresentation( + { ...expert, evaluationEnds: new Date(now + Number(minutes) * 60000).toISOString() }, + true, + now, + ); + expect(result.label).toBe("До конца оценивания"); + expect(result.value).toBe(value); + expect(result.countdown).toBe(true); + }); + it("prioritizes no assignments and completion even after deadline", () => { + const overdue = { ...expert, evaluationEnds: new Date(now - 1000).toISOString() }; + expect( + expertWidgetPresentation({ ...overdue, assigned: 0, remaining: 0 }, true, now), + ).toMatchObject({ text: "Нет назначенных проектов", tone: "neutral" }); + expect(expertWidgetPresentation({ ...overdue, remaining: 0 }, true, now)).toMatchObject({ + text: "Все проекты оценены", + tone: "green", + }); + }); + it("does not fabricate personal work in open mode", () => { + const result = expertWidgetPresentation( + { ...expert, mode: "open", assigned: null, remaining: null }, + true, + now, + ); + expect(result.text).toContain("Свободное оценивание"); + expect(result.text).not.toContain("Все проекты оценены"); + }); + it("does not imply mandatory submission in noncompetitive programs", () => + expect(expertWidgetPresentation(expert, false, now).text).toBe("Без обязательной сдачи")); + it("retains open evaluation and its deadline in noncompetitive programs", () => { + const result = expertWidgetPresentation( + { + ...expert, + mode: "open", + assigned: null, + remaining: null, + evaluationEnds: new Date(now).toISOString(), + }, + false, + now, + ); + expect(result.text).toBe("Свободное оценивание. Срок оценивания истёк"); + }); + it.each(["none", "not_submitted", "not_applicable"] as const)( + "keeps inactive stages for %s", + stage => + expect(participantSteps(stage).every(step => !step.current && !step.completed)).toBe(true), + ); + it("distinguishes current and completed stages", () => { + expect(participantSteps("submitted").map(s => s.completed)).toEqual([false, false, false]); + expect(participantSteps("review").map(s => s.completed)).toEqual([true, false, false]); + expect(participantSteps("evaluated").map(s => s.completed)).toEqual([true, true, true]); + for (const stage of ["submitted", "review", "evaluated"] as const) + expect(participantSteps(stage).filter(s => s.current && s.completed)).toHaveLength(0); + }); +}); diff --git a/projects/social_platform/src/app/api/program/program-widget-presentation.ts b/projects/social_platform/src/app/api/program/program-widget-presentation.ts new file mode 100644 index 000000000..ca1040f59 --- /dev/null +++ b/projects/social_platform/src/app/api/program/program-widget-presentation.ts @@ -0,0 +1,123 @@ +/** @format */ + +import { + ExpertWidget, + OrganizerWidget, + ParticipantStage, + ProgramWidgetRole, +} from "@domain/program/program-role-widget.model"; +import { Program } from "@domain/program/program.model"; + +export type WidgetTone = "neutral" | "green" | "yellow" | "red"; + +export interface ExpertWidgetStatus { + label: string | null; + value: string; + text: string; + countdown: boolean; + tone: WidgetTone; + tooltip: string; +} + +/** Полные единицы времени остаются видимыми; склонение учитывает 11–14 и 21/22. */ +function durationText(count: number, forms: [string, string, string]): string { + const plural = new Intl.PluralRules("ru-RU").select(count); + return `${count} ${forms[plural === "one" ? 0 : plural === "few" ? 1 : 2]}`; +} + +/** Только контекстные флаги; глобальная роль пользователя и наличие назначений не подходят. */ +export function programWidgetRole(program: Program | undefined): ProgramWidgetRole | null { + return program?.isUserManager + ? "organizer" + : program?.isUserExpert + ? "expert" + : program?.isUserMember + ? "participant" + : null; +} + +/** Цвет рассчитывается до округления. Нулевой знаменатель — неприменимая доля. */ +export function withoutProjectPresentation(metrics: OrganizerWidget) { + const { participants: total, participantsWithoutProject: count } = metrics; + const percentage = total > 0 ? (count / total) * 100 : null; + const tone: WidgetTone = + percentage === null + ? "neutral" + : percentage <= 10 + ? "green" + : percentage <= 25 + ? "yellow" + : "red"; + return { + tone, + percentage: percentage === null ? "—" : `${percentage.toFixed(1).replace(".", ",")}%`, + description: `${count} из ${total} участников без проекта`, + }; +} + +/** Состояния работы имеют приоритет над дедлайном; open не создаёт персональный остаток. */ +export function expertWidgetPresentation( + expert: ExpertWidget, + competitive: boolean, + now: number, +): ExpertWidgetStatus { + const deadline = expert.evaluationEnds ? Date.parse(expert.evaluationEnds) : NaN; + const tooltip = Number.isFinite(deadline) + ? new Intl.DateTimeFormat("ru-RU", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }).format(deadline) + : ""; + const modeLabel = expert.mode === "open" ? "Свободное оценивание" : null; + const status = ( + value: string, + tone: WidgetTone, + date = "", + label = modeLabel, + countdown = false, + ): ExpertWidgetStatus => ({ + label, + value, + text: label ? `${label}. ${value}` : value, + tone, + tooltip: date, + countdown, + }); + // Неконкурсная программа не подразумевает сдачу; свободное оценивание остаётся доступным. + if (!competitive && expert.mode === "distributed") + return status("Без обязательной сдачи", "neutral"); + if (expert.mode === "distributed") { + if (expert.assigned === 0) return status("Нет назначенных проектов", "neutral"); + if (expert.remaining === 0) return status("Все проекты оценены", "green"); + } + if (!Number.isFinite(deadline)) return status("Срок не установлен", "neutral"); + const remaining = deadline - now; + if (remaining <= 0) return status("Срок оценивания истёк", "red", tooltip); + const duration = + remaining < 3600000 + ? durationText(Math.ceil(remaining / 60000), ["минута", "минуты", "минут"]) + : remaining < 86400000 + ? durationText(Math.ceil(remaining / 3600000), ["час", "часа", "часов"]) + : durationText(Math.ceil(remaining / 86400000), ["день", "дня", "дней"]); + return status( + modeLabel ? `Осталось ${duration}` : duration, + remaining <= 48 * 3600000 ? "yellow" : "green", + tooltip, + modeLabel ?? "До конца оценивания", + true, + ); +} + +/** Проверка остаётся текущей без галочки; серверный evaluated завершает всю цепочку. */ +export function participantSteps(stage: ParticipantStage) { + const current = ["submitted", "review", "evaluated"].indexOf(stage); + return ["Отправлен", "Проверка", "Оценён"].map((label, index) => ({ + label, + current: stage !== "evaluated" && current === index, + completed: stage === "evaluated" || index < current, + })); +} diff --git a/projects/social_platform/src/app/api/program/program-widget-refresh.spec.ts b/projects/social_platform/src/app/api/program/program-widget-refresh.spec.ts new file mode 100644 index 000000000..4fec07113 --- /dev/null +++ b/projects/social_platform/src/app/api/program/program-widget-refresh.spec.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Subject } from "rxjs"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; +import { Project } from "@domain/project/project.model"; +import { ApplyProjectToProgramUseCase } from "./use-cases/apply-project-to-program.use-case"; +import { RateProjectUseCase } from "./use-cases/rate-project.use-case"; +import { SubmitCompetitiveProjectUseCase } from "@api/project/use-cases/submit-competitive-project.use-case"; +import { UpdateProgramLinkFieldsUseCase } from "@api/project/use-cases/update-program-link-fields.use-case"; + +describe("Program widget refresh after business operations", () => { + let response: Subject; + const changed = vi.fn(); + beforeEach(() => { + response = new Subject(); + changed.mockClear(); + const operation = () => response; + TestBed.configureTestingModule({ + providers: [ + { + provide: ProgramRepositoryPort, + useValue: { applyProjectToProgram: operation, submitCompettetiveProject: operation }, + }, + { provide: ProjectProgramRepositoryPort, useValue: { updateProgramLinkFields: operation } }, + { + provide: ProjectRatingRepositoryPort, + useValue: { rate: operation, formValuesToDTO: () => [] }, + }, + ], + }); + TestBed.inject(EventBus).on("ProgramWidgetChanged").subscribe(changed); + }); + const operations = [ + [ + "project link", + () => + TestBed.inject(ApplyProjectToProgramUseCase).execute(12, { + project: { id: 5 } as Project, + programFieldValues: [], + }), + ], + ["case/fields", () => TestBed.inject(UpdateProgramLinkFieldsUseCase).execute(34, [])], + ["submission", () => TestBed.inject(SubmitCompetitiveProjectUseCase).execute(34)], + ["evaluation", () => TestBed.inject(RateProjectUseCase).execute(5, [], {})], + ] as const; + it.each(operations)("refreshes after successful %s, never before response", (_, run) => { + run().subscribe(); + expect(changed).not.toHaveBeenCalled(); + response.next({}); + response.complete(); + expect(changed).toHaveBeenCalledOnce(); + }); + it.each(operations)("does not refresh after failed %s", (_, run) => { + run().subscribe(); + response.error(new Error("request failed")); + expect(changed).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/apply-project-to-program.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/apply-project-to-program.use-case.ts index 398cb6182..83e6cdf39 100644 --- a/projects/social_platform/src/app/api/program/use-cases/apply-project-to-program.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/apply-project-to-program.use-case.ts @@ -1,7 +1,9 @@ /** @format */ +import { EventBus } from "@domain/shared/event-bus"; + import { inject, Injectable } from "@angular/core"; -import { catchError, map, Observable, of } from "rxjs"; +import { catchError, map, Observable, of, tap } from "rxjs"; import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; import { fail, ok, Result } from "@domain/shared/result.type"; import { ApplyToProgramDTO } from "@domain/program/dto/apply-to-program.model"; @@ -9,6 +11,7 @@ import { ApplyToProgramDTO } from "@domain/program/dto/apply-to-program.model"; /** Сценарий: подать проект в программу; ошибка → `apply_project_to_program_error`. */ @Injectable({ providedIn: "root" }) export class ApplyProjectToProgramUseCase { + private readonly events = inject(EventBus); private readonly programRepositoryPort = inject(ProgramRepositoryPort); execute( @@ -16,6 +19,9 @@ export class ApplyProjectToProgramUseCase { dto: ApplyToProgramDTO, ): Observable> { return this.programRepositoryPort.applyProjectToProgram(programId, dto).pipe( + tap(() => + this.events.emit({ type: "ProgramWidgetChanged", payload: {}, occurredAt: new Date() }), + ), map(result => ok(result)), catchError(error => of(fail({ kind: "apply_project_to_program_error" as const, cause: error })), diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-role-widget.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-role-widget.use-case.spec.ts new file mode 100644 index 000000000..710e61413 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-role-widget.use-case.spec.ts @@ -0,0 +1,49 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { of, throwError } from "rxjs"; +import { describe, expect, it, vi } from "vitest"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { GetProgramRoleWidgetUseCase } from "./get-program-role-widget.use-case"; + +describe("GetProgramRoleWidgetUseCase", () => { + it.each([ + [0, "network"], + [500, "network"], + [401, "unauthorized"], + [403, "forbidden"], + [404, "not_found"], + [409, "integrity"], + ])("maps status %i without leaking body", (status, kind) => { + TestBed.configureTestingModule({ + providers: [ + { + provide: ProgramRepositoryPort, + useValue: { + getRoleWidget: () => + throwError( + () => + new HttpErrorResponse({ status: Number(status), error: { secret: "raw body" } }), + ), + }, + }, + ], + }); + TestBed.inject(GetProgramRoleWidgetUseCase) + .execute(12) + .subscribe(result => expect(result).toEqual({ ok: false, error: kind })); + }); + it("uses the narrow endpoint port, never the manager API", () => { + const repository = { + getRoleWidget: vi.fn(() => of({ programId: 12, role: "expert" })), + getManagerOverview: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [{ provide: ProgramRepositoryPort, useValue: repository }], + }); + TestBed.inject(GetProgramRoleWidgetUseCase).execute(12).subscribe(); + expect(repository.getRoleWidget).toHaveBeenCalledWith(12); + expect(repository.getManagerOverview).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-role-widget.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-role-widget.use-case.ts new file mode 100644 index 000000000..e4daedf6c --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-role-widget.use-case.ts @@ -0,0 +1,34 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramRoleWidget, ProgramWidgetError } from "@domain/program/program-role-widget.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Ошибки не содержат raw body и не превращаются в нулевые метрики. */ +@Injectable({ providedIn: "root" }) +export class GetProgramRoleWidgetUseCase { + private readonly repository = inject(ProgramRepositoryPort); + /** Загружает одну ролевую сводку; HTTP-ошибка сохраняется как состояние ошибки. */ + execute(programId: number): Observable> { + return this.repository.getRoleWidget(programId).pipe( + map(data => ok(data)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramWidgetError = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : status === 409 + ? "integrity" + : "network"; + return of(fail(kind)); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts index 23f5e0de2..0b323babe 100644 --- a/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts @@ -1,7 +1,9 @@ /** @format */ +import { EventBus } from "@domain/shared/event-bus"; + import { inject, Injectable } from "@angular/core"; -import { catchError, map, Observable, of } from "rxjs"; +import { catchError, map, Observable, of, tap } from "rxjs"; import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; import { fail, ok, Result } from "@domain/shared/result.type"; @@ -9,6 +11,7 @@ import { fail, ok, Result } from "@domain/shared/result.type"; /** Сценарий: выставить оценку проекту (эксперт); ошибка → `rate_project_error`. */ @Injectable({ providedIn: "root" }) export class RateProjectUseCase { + private readonly events = inject(EventBus); private readonly projectRatingRepositoryPort = inject(ProjectRatingRepositoryPort); execute( @@ -19,6 +22,9 @@ export class RateProjectUseCase { const dto = this.projectRatingRepositoryPort.formValuesToDTO(criteria, outputVals); return this.projectRatingRepositoryPort.rate(projectId, dto).pipe( + tap(() => + this.events.emit({ type: "ProgramWidgetChanged", payload: {}, occurredAt: new Date() }), + ), map(() => ok(undefined)), catchError(error => of(fail({ kind: "rate_project_error" as const, cause: error }))), ); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.spec.ts new file mode 100644 index 000000000..d8943d630 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.spec.ts @@ -0,0 +1,158 @@ +/** @format */ + +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Subject, of } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; +import { Result, ok, fail } from "@domain/shared/result.type"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { ProjectFormService } from "./project-form.service"; +import { ProjectCoverResetService } from "./project-cover-reset.service"; +import { UpdateFormUseCase } from "../../use-cases/update-form.use-case"; +import { + ResetProjectCoverUseCase, + ResetProjectCoverError, +} from "../../use-cases/reset-project-cover.use-case"; + +@Component({ template: "", providers: [ProjectCoverResetService] }) +class EditorHost {} + +describe("ProjectCoverResetService", () => { + let form: ProjectFormService; + let service: ProjectCoverResetService; + let response: Subject>; + let execute: ReturnType; + let update: ReturnType; + let error: ReturnType; + let fixture: ReturnType>; + const defaultCover: ProjectCoverReset = { + coverImageAddress: "https://files.test/default.png", + isDefaultCover: true, + }; + + function project(id = 31, isDefaultCover: boolean | undefined = false): Project { + return { + ...Project.default(), + id, + isDefaultCover, + coverImageAddress: `https://files.test/${id}.png`, + region: "Москва", + industry: 1, + name: "Проект", + description: "Описание", + problem: "Проблема", + targetAudience: "Студенты", + trl: "4", + implementationDeadline: "2027-01-01", + achievements: [], + links: [], + partnerProgram: { + id: 73, + programId: 21, + programLinkId: 73, + isSubmitted: false, + canSubmit: true, + programFields: [], + programFieldValues: [], + }, + }; + } + + beforeEach(() => { + response = new Subject(); + execute = vi.fn(() => response); + update = vi.fn(() => of(ok(Project.default()))); + error = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + { provide: ResetProjectCoverUseCase, useValue: { execute } }, + { provide: UpdateFormUseCase, useValue: { execute: update } }, + { provide: SnackbarService, useValue: { error } }, + ], + }); + form = TestBed.inject(ProjectFormService); + form.initializeProjectData(project()); + fixture = TestBed.createComponent(EditorHost); + service = fixture.debugElement.injector.get(ProjectCoverResetService); + }); + + it("использует Project.id, игнорирует повторный клик и не очищает control перед ответом", () => { + service.reset(); + service.reset(); + expect(execute).toHaveBeenCalledExactlyOnceWith(31); + expect(form.coverImageAddress?.value).toContain("/31.png"); + expect(service.pending()).toBe(true); + response.next(ok(defaultCover)); + response.complete(); + expect(form.coverImageAddress?.value).toBe(defaultCover.coverImageAddress); + expect(form.isDefaultCover()).toBe(true); + expect(form.coverImageAddress?.valid).toBe(true); + expect(form.validateForm()).toBe(true); + expect(form.getFormValue().coverImageAddress).toBe(defaultCover.coverImageAddress); + expect(update).not.toHaveBeenCalled(); + expect(service.pending()).toBe(false); + }); + + it.each([true, undefined])("стандартность %s не разрешает сброс", flag => { + const data = project(); + data.isDefaultCover = flag; + form.initializeProjectData(data); + service.reset(); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each(["unavailable", "forbidden", "failed"] as const)( + "ошибка %s сохраняет прежний URL и показывает сообщение", + kind => { + service.reset(); + response.next(fail(kind)); + response.complete(); + expect(form.coverImageAddress?.value).toContain("/31.png"); + expect(form.isDefaultCover()).toBe(false); + expect(error).toHaveBeenCalledOnce(); + expect(service.pending()).toBe(false); + }, + ); + + it.each([31, 32])("поздний ответ не изменяет повторно инициализированный контекст %s", id => { + service.reset(); + form.initializeProjectData({ ...project(id), coverImageAddress: "https://files.test/new.png" }); + expect(response.observed).toBe(false); + response.next(ok(defaultCover)); + expect(form.coverImageAddress?.value).toBe("https://files.test/new.png"); + expect(form.isDefaultCover()).toBe(false); + expect(service.pending()).toBe(false); + }); + + it("уничтожение редактора отменяет подписку и не меняет форму", () => { + service.reset(); + fixture.destroy(); + response.next(ok(defaultCover)); + expect(response.observed).toBe(false); + expect(form.coverImageAddress?.value).toContain("/31.png"); + }); + + it("параллельная замена URL защищена от позднего результата", () => { + service.reset(); + form.coverImageAddress?.setValue("https://files.test/new.png"); + response.next(ok(defaultCover)); + response.complete(); + expect(form.coverImageAddress?.value).toBe("https://files.test/new.png"); + expect(form.isDefaultCover()).toBe(false); + }); + + it("сброс форм отменяет запрос, presentation cleanup сохраняет прежнюю семантику", () => { + form.presentationAddress?.setValue(""); + expect(update).toHaveBeenCalledExactlyOnceWith({ + id: 31, + data: { presentationAddress: "", draft: true }, + }); + service.reset(); + form.resetForms(); + response.next(ok(defaultCover)); + expect(form.isDefaultCover()).toBeNull(); + expect(form.currentProjectId()).toBeNull(); + expect(response.observed).toBe(false); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.ts new file mode 100644 index 000000000..0be329100 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.ts @@ -0,0 +1,60 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { finalize, takeUntil } from "rxjs"; +import { ResetProjectCoverUseCase } from "../../use-cases/reset-project-cover.use-case"; +import { ProjectFormService } from "./project-form.service"; + +/** + * Команда живёт вместе с редактором, а не отдельным шагом: переход к команде + * или кейсу не должен потерять ответ уже сохранённого сброса. Форма не очищается. + */ +@Injectable() +export class ProjectCoverResetService { + private readonly form = inject(ProjectFormService); + private readonly resetCover = inject(ResetProjectCoverUseCase); + private readonly snackbar = inject(SnackbarService); + private readonly destroyRef = inject(DestroyRef); + readonly pending = signal(false); + + /** + * Повторный клик игнорируется. Смена данных проекта (включая повторное открытие + * того же ID) и уничтожение редактора отменяют подписку до изменения новой формы. + */ + reset(): void { + const id = this.form.currentProjectId(); + if (this.pending() || this.form.isDefaultCover() !== false || id === null) return; + const previousUrl = this.form.coverImageAddress?.value; + this.pending.set(true); + this.form.coverResetPending = true; + this.resetCover + .execute(id) + .pipe( + takeUntil(this.form.contextChanged$), + takeUntilDestroyed(this.destroyRef), + finalize(() => { + this.pending.set(false); + this.form.coverResetPending = false; + }), + ) + .subscribe(result => { + // Защита от параллельной замены файла без смены контекста проекта. + if (this.form.coverImageAddress?.value !== previousUrl) return; + if (result.ok) { + this.form.coverImageAddress?.setValue(result.value.coverImageAddress); + this.form.coverImageAddress?.markAsDirty(); + this.form.isDefaultCover.set(true); + } else { + const message = + result.error === "unavailable" + ? "Стандартная обложка временно недоступна." + : result.error === "forbidden" + ? "Нет доступа к изменению обложки проекта." + : "Не удалось вернуть стандартную обложку. Попробуйте ещё раз."; + this.snackbar.error(message); + } + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.spec.ts index 9292ba814..176ec28fa 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.spec.ts @@ -1,76 +1,196 @@ /** @format */ -import { DestroyRef } from "@angular/core"; +import { DestroyRef, signal } from "@angular/core"; import { FormControl } from "@angular/forms"; import { TestBed } from "@angular/core/testing"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; +import { of, Subject } from "rxjs"; import { UpdateFormUseCase } from "../../use-cases/update-form.use-case"; import { ProjectFormAutosaveService } from "./project-form-autosave.service"; -import { Project } from "@domain/project/project.model"; +import { SnackbarService } from "@domain/shared/snackbar.service"; + +const QUEUE_KEY = "project-autosave-queue"; +const networkError = { ok: false, error: { kind: "network", status: 0 } }; describe("ProjectFormAutosaveService", () => { let service: ProjectFormAutosaveService; - let updateFormUseCase: any; + let update: ReturnType; + let errorFeedback: ReturnType; + const projectId = signal(31); beforeEach(() => { - updateFormUseCase = { execute: vi.fn() }; - updateFormUseCase.execute.mockReturnValue(of({ ok: true, value: Project.default() })); - + localStorage.removeItem(QUEUE_KEY); + projectId.set(31); + update = vi.fn().mockReturnValue(of({ ok: true })); + errorFeedback = vi.fn(); + // ActivatedRoute намеренно не предоставляется: autosave не зависит от дерева маршрутов. TestBed.configureTestingModule({ providers: [ ProjectFormAutosaveService, - { - provide: ActivatedRoute, - useValue: { snapshot: { params: { projectId: "42" } } }, - }, - { - provide: UpdateFormUseCase, - useValue: updateFormUseCase, - }, + { provide: UpdateFormUseCase, useValue: { execute: update } }, + { provide: SnackbarService, useValue: { error: errorFeedback } }, ], }); - service = TestBed.inject(ProjectFormAutosaveService); }); - it("отправляет draft autosave при очистке presentationAddress", () => { - const control = new FormControl("https://example.com/presentation"); - const destroyRef = TestBed.inject(DestroyRef); + afterEach(() => { + TestBed.resetTestingModule(); + localStorage.removeItem(QUEUE_KEY); + }); - service.bindDraftCleanupAutosave(control, "presentationAddress", destroyRef); - control.setValue(""); + function bind(field: "presentationAddress" | "coverImageAddress" = "presentationAddress") { + const control = new FormControl("https://example.test/file"); + service.bindDraftCleanupAutosave(control, field, TestBed.inject(DestroyRef), projectId); + return control; + } + + it.each(["presentationAddress", "coverImageAddress"] as const)( + "сохраняет очистку %s ровно один раз в проект 31", + field => { + bind(field).setValue(""); + expect(update).toHaveBeenCalledExactlyOnceWith({ + id: 31, + data: { [field]: "", draft: true }, + }); + }, + ); + + it.each([null, undefined, NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, "31"])( + "не отправляет и не сохраняет в очередь недопустимый ID %s", + id => { + projectId.set(id as number | null); + update.mockReturnValue(of(networkError)); + bind().setValue(""); + expect(update).not.toHaveBeenCalled(); + expect(localStorage.getItem(QUEUE_KEY)).toBeNull(); + }, + ); + + it("не считает null и новый URL командой очистки", () => { + const control = bind(); + control.setValue(null); + control.setValue("https://example.test/new-file"); + expect(update).not.toHaveBeenCalled(); + }); - expect(updateFormUseCase.execute).toHaveBeenCalledExactlyOnceWith({ - id: 42, - data: { - presentationAddress: "", - draft: true, - }, - }); + it("не отправляет запрос при заполнении пустого control без событий", () => { + bind().setValue("", { emitEvent: false }); + expect(update).not.toHaveBeenCalled(); }); - it("не отправляет autosave для непустого значения", () => { - const control = new FormControl(""); - const destroyRef = TestBed.inject(DestroyRef); + it("при network сохраняет реальный ID и повторяет его после перехода на другой проект", () => { + const control = bind(); + update.mockReturnValueOnce(of(networkError)); + control.setValue(""); + expect(JSON.parse(localStorage.getItem(QUEUE_KEY)!)).toEqual([ + { projectId: 31, field: "presentationAddress", value: "" }, + ]); + expect(errorFeedback).toHaveBeenCalledWith(expect.stringContaining("ещё не сохранена")); + projectId.set(32); + control.setValue("https://example.test/project-32", { emitEvent: false }); + control.setValue(""); + window.dispatchEvent(new Event("online")); + expect(update.mock.calls.map(([command]) => command.id)).toEqual([31, 32, 31]); + expect(localStorage.getItem(QUEUE_KEY)).toBeNull(); + }); + + it("сохраняет адрес ожидающего события при смене проекта внутри concatMap", () => { + const pending = new Subject(); + update.mockReturnValueOnce(pending); + const control = bind(); + control.setValue(""); + control.setValue(""); + projectId.set(32); + pending.next({ ok: true }); + pending.complete(); + expect(update.mock.calls.map(([command]) => command.id)).toEqual([31, 31]); + }); - service.bindDraftCleanupAutosave(control, "coverImageAddress", destroyRef); - control.setValue("https://example.com/cover.png"); + it("при повторной сетевой ошибке сохраняет очередь без дублей", () => { + update.mockReturnValue(of(networkError)); + const control = bind(); + control.setValue(""); + control.setValue(""); + window.dispatchEvent(new Event("online")); + expect(JSON.parse(localStorage.getItem(QUEUE_KEY)!)).toEqual([ + { projectId: 31, field: "presentationAddress", value: "" }, + ]); + }); - expect(updateFormUseCase.execute).not.toHaveBeenCalled(); + it("не скрывает ошибку сохранения, отличную от network", () => { + update.mockReturnValue(of({ ok: false, error: { kind: "unknown" } })); + bind().setValue(""); + expect(errorFeedback).toHaveBeenCalledWith(expect.stringContaining("сохранение черновика")); + expect(localStorage.getItem(QUEUE_KEY)).toBeNull(); }); - it("останавливает подписку после destroy", () => { - const control = new FormControl("https://example.com/cover.png"); - const destroyRef = TestBed.inject(DestroyRef); + it("отбрасывает повреждённые старые записи, сохраняя допустимые поля и строковые значения", () => { + localStorage.setItem( + QUEUE_KEY, + JSON.stringify([ + null, + 7, + {}, + [], + ...[null, "31", 0, -1, 1.1, Number.MAX_SAFE_INTEGER + 1].map(id => ({ + projectId: id, + field: "presentationAddress", + value: "", + })), + { projectId: 31, field: "name", value: "" }, + { projectId: 31, field: "coverImageAddress", value: false }, + { projectId: 31, field: "coverImageAddress", value: "" }, + { projectId: 32, field: "presentationAddress", value: "https://example.test/file" }, + ]), + ); + window.dispatchEvent(new Event("online")); + expect(update.mock.calls.map(([command]) => command)).toEqual([ + { id: 31, data: { coverImageAddress: "", draft: true } }, + { id: 32, data: { presentationAddress: "https://example.test/file", draft: true } }, + ]); + expect(localStorage.getItem(QUEUE_KEY)).toBeNull(); + }); - service.bindDraftCleanupAutosave(control, "coverImageAddress", destroyRef); + it.each(["broken json", "{}", "null", "42"])("не отправляет запрос для очереди %s", stored => { + localStorage.setItem(QUEUE_KEY, stored); + window.dispatchEvent(new Event("online")); + expect(update).not.toHaveBeenCalled(); + }); - // Уничтожаем TestBed чтобы триггерить DestroyRef + it("проверяет старую очередь и при запуске уже online", () => { TestBed.resetTestingModule(); + localStorage.setItem( + QUEUE_KEY, + JSON.stringify([ + { projectId: null, field: "presentationAddress", value: "" }, + { projectId: 31, field: "presentationAddress", value: "" }, + ]), + ); + TestBed.configureTestingModule({ + providers: [ + { provide: UpdateFormUseCase, useValue: { execute: update } }, + { provide: SnackbarService, useValue: { error: errorFeedback } }, + ], + }); + TestBed.inject(ProjectFormAutosaveService); + expect(update).toHaveBeenCalledExactlyOnceWith({ + id: 31, + data: { presentationAddress: "", draft: true }, + }); + }); + it("после destroy отменяет ожидающие события и не обрабатывает online", () => { + const pending = new Subject(); + update.mockReturnValueOnce(pending); + const control = bind(); control.setValue(""); - - expect(updateFormUseCase.execute).not.toHaveBeenCalled(); + control.setValue(""); + TestBed.resetTestingModule(); + pending.next(networkError); + pending.complete(); + control.setValue(""); + window.dispatchEvent(new Event("online")); + expect(update).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(QUEUE_KEY)).toBeNull(); }); }); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.ts index 34b4f7a49..a62d1b510 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form-autosave.service.ts @@ -2,10 +2,10 @@ import { DestroyRef, inject, Injectable } from "@angular/core"; import { AbstractControl } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; -import { concatMap, filter, fromEvent, tap } from "rxjs"; +import { concatMap, filter, fromEvent, map, tap } from "rxjs"; import { UpdateFormUseCase } from "../../use-cases/update-form.use-case"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { SnackbarService } from "@domain/shared/snackbar.service"; type AutosaveField = "presentationAddress" | "coverImageAddress"; @@ -20,12 +20,15 @@ const QUEUE_KEY = "project-autosave-queue"; /** Автосохраняет очистку файловых полей, повторяет при сетевой ошибке. */ @Injectable({ providedIn: "root" }) export class ProjectFormAutosaveService { - private readonly route = inject(ActivatedRoute); private readonly updateFormUseCase = inject(UpdateFormUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly snackbar = inject(SnackbarService); constructor() { // При возврате онлайна сразу проигрываем отложенные изменения. - fromEvent(window, "online").subscribe(() => this.flushQueue()); + fromEvent(window, "online") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.flushQueue()); // На случай, если сеть была восстановлена ещё до загрузки приложения. if (typeof navigator !== "undefined" && navigator.onLine) { @@ -33,28 +36,42 @@ export class ProjectFormAutosaveService { } } + /** + * Сохраняет пользовательскую очистку в контексте загруженного проекта формы. + * Инициализация файловых контролов должна выполняться с emitEvent: false. + * ID фиксируется при изменении, до concatMap: ожидание предыдущего запроса + * и переход редактора к другому проекту не должны менять адрес назначения. + */ bindDraftCleanupAutosave( control: AbstractControl | null, field: AutosaveField, destroyRef: DestroyRef, + projectId: () => number | null, ): void { if (!control) return; control.valueChanges .pipe( - filter(value => !value), - concatMap(() => { - const projectId = Number(this.route.snapshot.params["projectId"]); - + filter(value => value === ""), + map(value => ({ projectId: projectId(), field, value })), + filter((patch): patch is QueuedPatch => this.isQueuedPatch(patch)), + concatMap(patch => { return this.updateFormUseCase .execute({ - id: projectId, + id: patch.projectId, data: { [field]: "", draft: true }, }) .pipe( tap(result => { if (!result.ok && result.error.kind === "network") { - this.enqueue({ projectId, field, value: "" }); + this.enqueue(patch); + this.snackbar.error( + "Очистка ссылки на файл ещё не сохранена. Повторим при восстановлении сети.", + ); + } else if (!result.ok) { + this.snackbar.error( + "Не удалось сохранить очистку ссылки на файл. Повторите сохранение черновика.", + ); } }), ); @@ -65,6 +82,7 @@ export class ProjectFormAutosaveService { } private enqueue(patch: QueuedPatch): void { + if (!this.isQueuedPatch(patch)) return; const queue = this.readQueue(); // Дедуп по (projectId, field) — последняя запись побеждает. const dedup = queue.filter(q => !(q.projectId === patch.projectId && q.field === patch.field)); @@ -88,6 +106,7 @@ export class ProjectFormAutosaveService { this.enqueue({ projectId, field, value }); } }), + takeUntilDestroyed(this.destroyRef), ) .subscribe(); }); @@ -95,9 +114,28 @@ export class ProjectFormAutosaveService { private readQueue(): QueuedPatch[] { try { - return JSON.parse(localStorage.getItem(QUEUE_KEY) ?? "[]") as QueuedPatch[]; + const stored: unknown = JSON.parse(localStorage.getItem(QUEUE_KEY) ?? "[]"); + const queue = Array.isArray(stored) ? stored.filter(this.isQueuedPatch) : []; + // Старые записи с NaN превращались в null при JSON.stringify. Удаляем + // повреждённые записи из хранилища до любого повтора запроса. + if (!Array.isArray(stored) || queue.length !== stored.length) { + localStorage.setItem(QUEUE_KEY, JSON.stringify(queue)); + } + return queue; } catch { return []; } } + + private isQueuedPatch(value: unknown): value is QueuedPatch { + if (!value || typeof value !== "object") return false; + const patch = value as Partial; + return ( + typeof patch.projectId === "number" && + Number.isSafeInteger(patch.projectId) && + patch.projectId > 0 && + (patch.field === "presentationAddress" || patch.field === "coverImageAddress") && + typeof patch.value === "string" + ); + } } diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts index 8bfc91dc5..7f62942cf 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts @@ -1,7 +1,6 @@ /** @format */ import { TestBed } from "@angular/core/testing"; -import { ActivatedRoute } from "@angular/router"; import { of } from "rxjs"; import { ProjectFormService } from "./project-form.service"; import { UpdateFormUseCase } from "../../use-cases/update-form.use-case"; @@ -9,18 +8,17 @@ import { Project } from "@domain/project/project.model"; describe("ProjectFormService", () => { let service: ProjectFormService; + let update: ReturnType; beforeEach(() => { + localStorage.removeItem("project-autosave-queue"); + update = vi.fn().mockReturnValue(of({ ok: true })); TestBed.configureTestingModule({ providers: [ ProjectFormService, - { - provide: ActivatedRoute, - useValue: { snapshot: { params: { projectId: "42" } } }, - }, { provide: UpdateFormUseCase, - useValue: { execute: vi.fn().mockReturnValue(of({ ok: true })) }, + useValue: { execute: update }, }, ], }); @@ -107,4 +105,121 @@ describe("ProjectFormService", () => { expect(service.region?.value).toBe("Миксва"); expect(service.region?.valid).toBe(true); }); + + it("начальное заполнение пустых файлов не отправляет cleanup и сохраняет события обычных полей", () => { + const project = { + ...Project.default(), + id: 31, + presentationAddress: "", + coverImageAddress: "", + }; + const nameChanged = vi.fn(); + service.name!.valueChanges.subscribe(nameChanged); + service.initializeProjectData(project); + expect(update).not.toHaveBeenCalled(); + expect(nameChanged).toHaveBeenCalledWith(project.name); + expect(service.getFormValue()).not.toHaveProperty("presentationAddress"); + expect(service.getFormValue()).not.toHaveProperty("coverImageAddress"); + }); + + it.each(["presentationAddress", "coverImageAddress"] as const)( + "намеренная очистка %s сохраняется в явном payload и autosave", + field => { + service.initializeProjectData({ + ...Project.default(), + id: 31, + [field]: "https://example.test/file", + }); + service.getForm().get(field)!.setValue(""); + expect(service.getFormValue()[field]).toBe(""); + expect(update).toHaveBeenCalledExactlyOnceWith({ + id: 31, + data: { [field]: "", draft: true }, + }); + }, + ); + + it("помнит удаление файла, загруженного после открытия изначально пустой формы", () => { + service.initializeProjectData({ ...Project.default(), id: 31, presentationAddress: "" }); + service.presentationAddress!.setValue("https://example.test/new-file"); + service.presentationAddress!.setValue(""); + expect(service.getFormValue().presentationAddress).toBe(""); + }); + + it("замена удалённого файла отправляет новый URL вместо команды очистки", () => { + service.initializeProjectData({ ...Project.default(), id: 31 }); + service.presentationAddress!.setValue(""); + service.presentationAddress!.setValue("https://example.test/replacement"); + expect(service.getFormValue().presentationAddress).toBe("https://example.test/replacement"); + }); + + it("при смене проекта сбрасывает историю файлов и использует новый Project.id", () => { + service.initializeProjectData({ ...Project.default(), id: 31 }); + service.presentationAddress!.setValue(""); + service.initializeProjectData({ + ...Project.default(), + id: 32, + presentationAddress: "", + coverImageAddress: "https://example.test/cover-32", + }); + expect(update).toHaveBeenCalledTimes(1); + expect(service.getFormValue()).not.toHaveProperty("presentationAddress"); + service.coverImageAddress!.setValue(""); + expect(update).toHaveBeenLastCalledWith({ + id: 32, + data: { coverImageAddress: "", draft: true }, + }); + }); + + it("не смешивает projectId и programLinkId", () => { + service.initializeProjectData({ + ...Project.default(), + id: 31, + partnerProgram: { + id: 73, + programId: 21, + programLinkId: 73, + isSubmitted: false, + canSubmit: true, + programFields: [], + programFieldValues: [], + }, + }); + service.presentationAddress!.setValue(""); + expect(service.relationId()).toBe(73); + expect(update).toHaveBeenCalledExactlyOnceWith({ + id: 31, + data: { presentationAddress: "", draft: true }, + }); + }); + + it("сохраняет прежнюю семантику stripNullish для обычных полей и значения false/0", () => { + service.initializeProjectData({ ...Project.default(), id: 31, presentationAddress: "" }); + service.getForm().patchValue({ + name: "Название", + description: "", + actuality: " ", + problem: null, + trl: undefined, + draft: false, + industryId: 0, + }); + const payload = service.getFormValue(); + expect(payload).toMatchObject({ name: "Название", draft: false, industryId: 0 }); + for (const field of ["description", "actuality", "problem", "trl", "presentationAddress"]) { + expect(payload).not.toHaveProperty(field); + } + }); + + it("reset и destroy не вызывают позднюю очистку прежнего проекта", () => { + service.initializeProjectData({ ...Project.default(), id: 31 }); + service.resetForms(); + service.presentationAddress!.setValue(""); + expect(update).not.toHaveBeenCalled(); + expect(service.getFormValue()).not.toHaveProperty("presentationAddress"); + service.initializeProjectData({ ...Project.default(), id: 32 }); + TestBed.resetTestingModule(); + service.presentationAddress!.setValue(""); + expect(update).not.toHaveBeenCalled(); + }); }); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts index c99ace5c9..58d1abc8d 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts @@ -19,11 +19,24 @@ import { } from "./project-form.factory"; import { ProjectFormAutosaveService } from "./project-form-autosave.service"; import { findCanonicalRussianRegion } from "@core/consts/lists/russian-regions-list.const"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Subject } from "rxjs"; + +const FILE_FIELDS = ["presentationAddress", "coverImageAddress"] as const; /** Управляет основной формой проекта и формой дополнительных полей партнерской программы. */ @Injectable({ providedIn: "root" }) export class ProjectFormService { private projectForm!: FormGroup; private additionalForm!: FormGroup; + private readonly projectId = signal(null); + readonly currentProjectId = this.projectId.asReadonly(); + /** null означает, что detail ещё не подтвердил вид обложки; удаление запрещено. */ + readonly isDefaultCover = signal(null); + private readonly contextChanged = new Subject(); + readonly contextChanged$ = this.contextChanged.asObservable(); + /** Сохранение всей формы не должно вернуть прежний URL во время серверного сброса. */ + coverResetPending = false; + private readonly fileHadValue = new Set<(typeof FILE_FIELDS)[number]>(); private readonly fb = inject(FormBuilder); private readonly destroyRef = inject(DestroyRef); @@ -39,19 +52,36 @@ export class ProjectFormService { private initializeForm(): void { this.projectForm = createProjectForm(this.fb); - this.projectFormAutosaveService.bindDraftCleanupAutosave( - this.presentationAddress, - "presentationAddress", - this.destroyRef, - ); - this.projectFormAutosaveService.bindDraftCleanupAutosave( - this.coverImageAddress, - "coverImageAddress", - this.destroyRef, - ); + for (const field of FILE_FIELDS) { + const control = this.projectForm.get(field)!; + control.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { + if (typeof value === "string" && value.length > 0) this.fileHadValue.add(field); + }); + this.projectFormAutosaveService.bindDraftCleanupAutosave( + control, + field, + this.destroyRef, + this.projectId, + ); + } } + /** + * Устанавливает контекст именно Project, а не его связи с программой. + * Заполнение файлов не является удалением: только эти контролы заполняются + * без событий. История очистки сбрасывается при каждом новом наборе данных. + */ public initializeProjectData(project: Project): void { + this.contextChanged.next(); + this.projectId.set(null); + this.isDefaultCover.set(project.isDefaultCover ?? null); + this.fileHadValue.clear(); + for (const field of FILE_FIELDS) { + const value = project[field] ?? ""; + this.projectForm.get(field)!.setValue(value, { emitEvent: false }); + if (value) this.fileHadValue.add(field); + } + this.projectId.set(Number.isSafeInteger(project.id) && project.id > 0 ? project.id : null); const rawRegion = typeof project.region === "string" ? project.region.trim() : ""; const canonicalRegion = findCanonicalRussianRegion(rawRegion); this.region?.setValidators([ @@ -71,8 +101,6 @@ export class ProjectFormService { actuality: project.actuality ?? "", trl: project.trl ?? "", problem: project.problem ?? "", - presentationAddress: project.presentationAddress, - coverImageAddress: project.coverImageAddress, partnerProgramId: project.partnerProgram?.programId ?? null, }); this.region?.updateValueAndValidity({ emitEvent: false }); @@ -128,8 +156,20 @@ export class ProjectFormService { return this.projectForm.valid; } + /** + * Оставляет прежнюю очистку обычных полей, но сохраняет явное удаление файла. + * Учитывается и файл, загруженный уже после открытия формы. Изначально пустой + * нетронутый control не становится командой очистки; dirty для этого недостаточно, + * поскольку программное обновление через CVA и patchValue имеет разную семантику. + */ public getFormValue(): any { - return stripNullish(this.projectForm.value); + const payload = stripNullish(this.projectForm.value); + for (const field of FILE_FIELDS) { + if (this.fileHadValue.has(field) && this.projectForm.get(field)?.value === "") { + payload[field] = ""; + } + } + return payload; } // Геттеры для быстрого доступа к контролам основной формы @@ -226,7 +266,12 @@ export class ProjectFormService { return this.additionalForm ? stripNullish(this.additionalForm.value) : {}; } + /** Сбрасывает контекст до контролов, чтобы сброс формы не сохранялся в прежний проект. */ public resetForms(): void { + this.contextChanged.next(); + this.projectId.set(null); + this.isDefaultCover.set(null); + this.fileHadValue.clear(); this.projectForm.reset(); this.additionalForm?.reset(); this.clearFormArrays(); diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts index 973149f2f..08f91ca07 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts @@ -2,7 +2,7 @@ import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, FormControl } from "@angular/forms"; import { ActivatedRoute, provideRouter, Router } from "@angular/router"; import { BehaviorSubject, of, Subject, throwError } from "rxjs"; import { HttpErrorResponse } from "@angular/common/http"; @@ -18,7 +18,7 @@ import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-prog import { programLinkFields } from "@domain/project/program-link-fields.fixture"; import { ProgramLinkFields } from "@domain/project/program-link-fields.model"; import { Project } from "@domain/project/project.model"; -import { ok } from "@domain/shared/result.type"; +import { fail, ok } from "@domain/shared/result.type"; import { AssignProjectProgramUseCase } from "@api/program/use-cases/assign-project-program"; import { DeleteProjectUseCase } from "../../use-cases/delete-project.use-case"; import { UpdateFormUseCase } from "../../use-cases/update-form.use-case"; @@ -56,6 +56,16 @@ describe("ProjectsEditInfoService canonical relation integration", () => { applyOpenSendDescisionLateModal: vi.fn(), applyOpenAssignProjectModal: vi.fn(), }; + + it("не сохраняет прежний URL обложки параллельно серверному сбросу", () => { + TestBed.inject(ProjectFormService).coverResetPending = true; + service.saveProjectAsDraft(); + service.saveProjectAsPublished(); + service.submitProjectForm(); + expect(update.execute).not.toHaveBeenCalled(); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }); function project(id = 55): Project { // The embedded legacy program is deliberately B. Explicit route link A is authoritative. return { @@ -194,6 +204,74 @@ describe("ProjectsEditInfoService canonical relation integration", () => { expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); }); + it("черновик передаёт обе явные очистки одним запросом", () => { + service.loadProgramTagsAndProject(); + service.projectForm.addControl("presentationAddress", new FormControl("")); + service.projectForm.addControl("coverImageAddress", new FormControl("")); + service.saveProjectAsDraft(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { name: "Project", draft: true, presentationAddress: "", coverImageAddress: "" }, + }); + }); + + it("публикация ждёт подтверждения очистки, затем не отправляет запрещённые backend пустые поля", () => { + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")!.setValue("B"); + service.projectForm.addControl("presentationAddress", new FormControl("")); + service.projectForm.addControl("coverImageAddress", new FormControl("new-cover.png")); + service.projectForm.get("draft")!.setValue(false); + const cleanup = new Subject>>(); + update.execute.mockReturnValueOnce(cleanup); + service.submitProjectForm(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { presentationAddress: "", draft: true }, + }); + expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); + cleanup.next(ok(project())); + cleanup.complete(); + expect(update.execute).toHaveBeenNthCalledWith(2, { + id: 55, + data: { name: "Project", draft: false, coverImageAddress: "new-cover.png" }, + }); + expect(service.projectForm.get("presentationAddress")!.value).toBe(""); + expect(TestBed.inject(SnackbarService).success).toHaveBeenCalledOnce(); + }); + + it.each(["network", "unknown"])( + "ошибка очистки %s прерывает публикацию без ложного успеха", + kind => { + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")!.setValue("B"); + service.projectForm.addControl("presentationAddress", new FormControl("")); + service.projectForm.addControl("coverImageAddress", new FormControl("")); + service.projectForm.get("draft")!.setValue(false); + update.execute.mockReturnValueOnce(of(fail({ kind, status: 0 }))); + service.submitProjectForm(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { presentationAddress: "", coverImageAddress: "", draft: true }, + }); + expect(service.projFormIsSubmitting$().status).toBe("failure"); + expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); + expect(TestBed.inject(SnackbarService).error).toHaveBeenCalledOnce(); + }, + ); + + it("уничтожение редактора во время очистки отменяет последующую публикацию", () => { + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")!.setValue("B"); + service.projectForm.addControl("presentationAddress", new FormControl("")); + service.projectForm.get("draft")!.setValue(false); + const cleanup = new Subject>>(); + update.execute.mockReturnValueOnce(cleanup); + service.submitProjectForm(); + TestBed.resetTestingModule(); + cleanup.next(ok(project())); + expect(update.execute).toHaveBeenCalledOnce(); + }); + it("empty case blocks publish; selected case PUT/submit both use A", () => { service.loadProgramTagsAndProject(); service.saveProjectAsPublished(); diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts index 5a91d080a..eb10acc21 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts @@ -231,6 +231,7 @@ export class ProjectsEditInfoService { } saveProjectAsPublished(): void { + if (this.projectFormService.coverResetPending) return; if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; if (!this.submissionAvailable()) return; this.projectForm.get("draft")?.patchValue(false); @@ -275,6 +276,7 @@ export class ProjectsEditInfoService { } saveProjectAsDraft(): void { + if (this.projectFormService.coverResetPending) return; if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; this.clearAllValidationErrors(); this.projectForm.get("draft")?.patchValue(true); @@ -288,7 +290,9 @@ export class ProjectsEditInfoService { } } + /** Сохраняет форму; перед публикацией подтверждает намеренную очистку файлов в режиме черновика. */ submitProjectForm(): void { + if (this.projectFormService.coverResetPending) return; if (!this.additionalContextReady()) return; const isDraft = this.projectForm.get("draft")?.value === true; @@ -298,8 +302,8 @@ export class ProjectsEditInfoService { const rawPayload = this.projectFormService.getFormValue(); const payload = { ...rawPayload }; - // In a verified canonical edit this is NOT a legacy bind/unbind command. - // Preserve the control and dedicated assignment flow; do not mutate raw form data. + // Редактирование проверенной связи не является командой привязки/отвязки. + // Сохраняем control и отдельный сценарий привязки, не изменяя исходные данные формы. if (this.activeProgramLinkId()) delete payload.partnerProgramId; const projectId = Number(this.route.snapshot.paramMap.get("projectId")); @@ -325,8 +329,7 @@ export class ProjectsEditInfoService { } } - this.updateFormUseCase - .execute({ id: projectId, data: payload }) + this.saveProjectPayload(projectId, payload, isDraft) .pipe( switchMap(result => { if (!result.ok) { @@ -339,6 +342,7 @@ export class ProjectsEditInfoService { }), switchMap(() => this.savePartners(projectId)), switchMap(() => this.saveOrEditResources(projectId)), + takeUntilDestroyed(this.destroyRef), ) .subscribe({ next: () => { @@ -355,6 +359,35 @@ export class ProjectsEditInfoService { }); } + /** + * Backend принимает пустые файловые поля только с draft: true. Сначала явно + * сохраняем очистку и ждём подтверждения, затем публикуем без этих полей. + * Простое удаление ключей из payload могло бы скрыть несохранённую очистку + * после сетевой ошибки. Остальные поля и серверные правила не меняются. + */ + private saveProjectPayload(projectId: number, payload: Partial, isDraft: boolean) { + const clearedFiles: Partial = {}; + const publicationPayload = { ...payload }; + for (const field of ["presentationAddress", "coverImageAddress"] as const) { + if (payload[field] === "") { + clearedFiles[field] = ""; + delete publicationPayload[field]; + } + } + if (isDraft || Object.keys(clearedFiles).length === 0) { + return this.updateFormUseCase.execute({ id: projectId, data: payload }); + } + return this.updateFormUseCase + .execute({ id: projectId, data: { ...clearedFiles, draft: true } }) + .pipe( + switchMap(result => + result.ok + ? this.updateFormUseCase.execute({ id: projectId, data: publicationPayload }) + : of(result), + ), + ); + } + closeSendingDescisionModal(): void { this.projectsEditUIInfoService.applyCloseSendDescisionModal(); if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; diff --git a/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.spec.ts b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.spec.ts new file mode 100644 index 000000000..194a09924 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.spec.ts @@ -0,0 +1,57 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ResetProjectCoverUseCase } from "./reset-project-cover.use-case"; + +describe("ResetProjectCoverUseCase", () => { + let reset: ReturnType; + let useCase: ResetProjectCoverUseCase; + beforeEach(() => { + reset = vi.fn(); + TestBed.configureTestingModule({ + providers: [{ provide: ProjectRepositoryPort, useValue: { resetCover: reset } }], + }); + useCase = TestBed.inject(ResetProjectCoverUseCase); + }); + + it.each([NaN, 0, -1, Infinity, 1.5])("не отправляет неверный id %s", async id => { + expect(await firstValueFrom(useCase.execute(id))).toEqual({ ok: false, error: "failed" }); + expect(reset).not.toHaveBeenCalled(); + }); + + it("возвращает фактическую обложку из API", async () => { + const value = { coverImageAddress: "https://files.test/default.png", isDefaultCover: true }; + reset.mockReturnValue(of(value)); + expect(await firstValueFrom(useCase.execute(31))).toEqual({ ok: true, value }); + }); + + it.each([ + { coverImageAddress: "", isDefaultCover: true }, + { coverImageAddress: "custom", isDefaultCover: false }, + ])("не принимает некорректный успех %s", async value => { + reset.mockReturnValue(of(value)); + expect(await firstValueFrom(useCase.execute(31))).toEqual({ ok: false, error: "failed" }); + }); + + it.each([ + [409, "unavailable"], + [403, "forbidden"], + [401, "forbidden"], + [500, "failed"], + [0, "failed"], + ])("отображает безопасную ошибку HTTP %s", async (status, expected) => { + reset.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: Number(status), + error: { code: "default_cover_unavailable", detail: "private body" }, + }), + ), + ); + expect(await firstValueFrom(useCase.execute(31))).toEqual({ ok: false, error: expected }); + }); +}); diff --git a/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.ts new file mode 100644 index 000000000..323c1ac30 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +export type ResetProjectCoverError = "unavailable" | "forbidden" | "failed"; + +/** Команда сброса с безопасными ошибками: серверный HTTP body не выводится пользователю. */ +@Injectable({ providedIn: "root" }) +export class ResetProjectCoverUseCase { + private readonly repository = inject(ProjectRepositoryPort); + + /** Принимает только реальный Project.id; успешный ответ обязан содержать стандартный URL. */ + execute(id: number): Observable> { + if (!Number.isSafeInteger(id) || id <= 0) return of(fail("failed")); + return this.repository.resetCover(id).pipe( + map(result => + result.isDefaultCover === true && !!result.coverImageAddress + ? ok(result) + : fail("failed"), + ), + catchError((error: unknown) => { + if (error instanceof HttpErrorResponse) { + if (error.status === 409 && error.error?.code === "default_cover_unavailable") { + return of(fail("unavailable")); + } + if (error.status === 401 || error.status === 403) { + return of(fail("forbidden")); + } + } + return of(fail("failed")); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-cases/submit-competitive-project.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/submit-competitive-project.use-case.ts index 322ec83b7..ef2d757b1 100644 --- a/projects/social_platform/src/app/api/project/use-cases/submit-competitive-project.use-case.ts +++ b/projects/social_platform/src/app/api/project/use-cases/submit-competitive-project.use-case.ts @@ -1,7 +1,9 @@ /** @format */ +import { EventBus } from "@domain/shared/event-bus"; + import { inject, Injectable } from "@angular/core"; -import { catchError, map, Observable, of } from "rxjs"; +import { catchError, map, Observable, of, tap } from "rxjs"; import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; import { Project } from "@domain/project/project.model"; import { fail, ok, Result } from "@domain/shared/result.type"; @@ -9,12 +11,16 @@ import { fail, ok, Result } from "@domain/shared/result.type"; /** Сценарий: подать проект на конкурс программы; ошибка → `submit_competitive_project_error`. */ @Injectable({ providedIn: "root" }) export class SubmitCompetitiveProjectUseCase { + private readonly events = inject(EventBus); private readonly programRepositoryPort = inject(ProgramRepositoryPort); execute( relationId: number, ): Observable> { return this.programRepositoryPort.submitCompettetiveProject(relationId).pipe( + tap(() => + this.events.emit({ type: "ProgramWidgetChanged", payload: {}, occurredAt: new Date() }), + ), map(project => ok(project)), catchError(error => of(fail({ kind: "submit_competitive_project_error" as const, cause: error })), diff --git a/projects/social_platform/src/app/api/project/use-cases/update-program-link-fields.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/update-program-link-fields.use-case.ts index 9f3b29aeb..352267eed 100644 --- a/projects/social_platform/src/app/api/project/use-cases/update-program-link-fields.use-case.ts +++ b/projects/social_platform/src/app/api/project/use-cases/update-program-link-fields.use-case.ts @@ -1,16 +1,19 @@ /** @format */ +import { EventBus } from "@domain/shared/event-bus"; + import { inject, Injectable } from "@angular/core"; -import { catchError, map, Observable, of } from "rxjs"; +import { catchError, map, Observable, of, tap } from "rxjs"; import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; import { ProgramLinkFieldsError } from "@domain/project/program-link-fields.model"; import { mapProgramLinkFieldsError } from "../program-link-fields-error"; import { fail, ok, Result } from "@domain/shared/result.type"; -/** Partial update of additional values for exactly one program-project link. */ +/** Обновляет дополнительные значения строго одной связи проекта и программы. */ @Injectable({ providedIn: "root" }) export class UpdateProgramLinkFieldsUseCase { + private readonly events = inject(EventBus); private readonly projectProgramRepositoryPort = inject(ProjectProgramRepositoryPort); execute( @@ -18,6 +21,9 @@ export class UpdateProgramLinkFieldsUseCase { newValues: ProjectNewAdditionalProgramFields[], ): Observable> { return this.projectProgramRepositoryPort.updateProgramLinkFields(programLinkId, newValues).pipe( + tap(() => + this.events.emit({ type: "ProgramWidgetChanged", payload: {}, occurredAt: new Date() }), + ), map(() => ok(undefined)), catchError(error => of(fail(mapProgramLinkFieldsError(error)))), ); diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts index 0cd3f4c67..1fb175580 100644 --- a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -1,6 +1,7 @@ /** @format */ import { HttpParams } from "@angular/common/http"; +import { ProgramRoleWidget } from "../program-role-widget.model"; import { Observable } from "rxjs"; import { ApiPagination } from "../../other/api-pagination.model"; import { Program, ProgramDataSchema } from "../program.model"; @@ -27,6 +28,8 @@ import { /** Порт репозитория программ: список/детали/создание/регистрация, проекты/участники/фильтры. */ export abstract class ProgramRepositoryPort { + /** Сводка текущего пользователя без обращения к manager-only API. */ + abstract getRoleWidget(programId: number): Observable; abstract getAll( skip: number, take: number, diff --git a/projects/social_platform/src/app/domain/program/program-role-widget.model.ts b/projects/social_platform/src/app/domain/program/program-role-widget.model.ts new file mode 100644 index 000000000..874b04538 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-role-widget.model.ts @@ -0,0 +1,49 @@ +/** + * Данные Angular-проектов только для чтения; currentProjectApplication не используется. + * + * @format + */ + +export type ProgramWidgetRole = "organizer" | "expert" | "participant"; +export type ParticipantStage = + | "none" + | "not_submitted" + | "submitted" + | "review" + | "evaluated" + | "not_applicable"; +export interface ParticipantWidget { + participantProject: { id: number; name: string; programLinkId: number } | null; + caseProvided: boolean; + caseName: string | null; + stage: ParticipantStage; + submissionOpen: boolean; +} +export interface OrganizerWidget { + participants: number; + projects: number; + submittedSolutions: number | null; + participantsWithoutProject: number; +} +export interface ExpertWidget { + mode: "open" | "distributed"; + assigned: number | null; + remaining: number | null; + evaluationEnds: string | null; +} +interface WidgetContext { + programId: number; + isCompetitive: boolean; +} +export type ProgramRoleWidget = WidgetContext & + ( + | { role: "participant"; participant: ParticipantWidget } + | { role: "organizer"; organizer: OrganizerWidget } + | { role: "expert"; expert: ExpertWidget } + ); +export type ProgramWidgetError = + | "network" + | "forbidden" + | "unauthorized" + | "not_found" + | "integrity"; diff --git a/projects/social_platform/src/app/domain/program/program.model.ts b/projects/social_platform/src/app/domain/program/program.model.ts index 9a01a9e43..e4e7e214e 100644 --- a/projects/social_platform/src/app/domain/program/program.model.ts +++ b/projects/social_platform/src/app/domain/program/program.model.ts @@ -31,6 +31,7 @@ export class Program { likesCount!: number; isUserLiked!: boolean; isUserManager!: boolean; + isUserExpert?: boolean; isUserMember!: boolean; currentProjectApplication!: ProgramCurrentApplication | null; welcomeAcknowledgedAt!: string | null; 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 f3fe5d2a2..24c9dbdd0 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 @@ -4,6 +4,7 @@ import { BehaviorSubject, Observable } from "rxjs"; import { Project, ProjectCount } 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 { @@ -16,4 +17,6 @@ export abstract class ProjectRepositoryPort { abstract update(id: number, data: Partial): Observable; abstract deleteOne(id: number): Observable; abstract refreshCount(): Observable; + /** Сбрасывает обложку с обычными серверными правами редактирования проекта. */ + abstract resetCover(id: number): Observable; } diff --git a/projects/social_platform/src/app/domain/project/project-cover.model.ts b/projects/social_platform/src/app/domain/project/project-cover.model.ts new file mode 100644 index 000000000..1abc97fd1 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/project-cover.model.ts @@ -0,0 +1,10 @@ +/** + * Результат сохранённого сервером сброса; пустая обложка не является успехом. + * + * @format + */ + +export interface ProjectCoverReset { + coverImageAddress: string; + isDefaultCover: true; +} 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 f37c9d0f5..6b6e6d669 100644 --- a/projects/social_platform/src/app/domain/project/project.model.ts +++ b/projects/social_platform/src/app/domain/project/project.model.ts @@ -44,6 +44,8 @@ export class Project { viewsCount!: number; cover!: null | string; coverImageAddress!: string | null; + /** Канонический признак из detail API; сокращённые списки его не возвращают. */ + isDefaultCover?: boolean; collaborators!: Collaborator[]; collaborator?: Collaborator; links!: string[]; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts index 729af936c..320e46ac6 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -1,6 +1,7 @@ /** @format */ import { inject, Injectable } from "@angular/core"; +import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; import { ApiService } from "@corelib"; import { Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; @@ -30,6 +31,10 @@ import { /** HTTP-адаптер программ: `/programs`, `/auth/public-users` (детали, проекты, участники, фильтры, регистрация). */ @Injectable({ providedIn: "root" }) export class ProgramHttpAdapter { + /** Отдельный ролевой endpoint: доступ и агрегации проверяет сервер. */ + getRoleWidget(programId: number): Observable { + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/analytics-widget/`); + } private readonly PROGRAMS_URL = "/programs"; private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; private readonly apiService = inject(ApiService); 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 8568a024c..95e5146dc 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 @@ -30,6 +30,8 @@ export interface ProjectDto { presentationAddress: string; cover: string | null; coverImageAddress: string | null; + /** Поле detail API после общего преобразования snake_case в camelCase. */ + isDefaultCover?: boolean; links: string[]; numberOfCollaborators: number; viewsCount: number; diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-cover-contract.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-cover-contract.spec.ts new file mode 100644 index 000000000..2e6cc9f21 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-cover-contract.spec.ts @@ -0,0 +1,55 @@ +/** @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 { API_URL } from "@core/lib/providers"; +import { CamelcaseInterceptor } from "@core/lib/interceptors/camelcase.interceptor"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProjectHttpAdapter } from "./project-http.adapter"; + +describe("Контракт стандартной обложки через HTTP и общий 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.each([true, false])("detail преобразует is_default_cover=%s в модель Angular", flag => { + TestBed.inject(ProjectHttpAdapter) + .fetchOne(31) + .subscribe(value => { + expect(value.isDefaultCover).toBe(flag); + expect(value.coverImageAddress).toBe("https://files.test/cover.png"); + }); + TestBed.inject(HttpTestingController).expectOne("https://local.test/projects/31/").flush({ + id: 31, + is_default_cover: flag, + cover_image_address: "https://files.test/cover.png", + }); + }); + + it("POST reset-cover возвращает новый URL и серверный флаг", () => { + TestBed.inject(ProjectHttpAdapter) + .resetCover(31) + .subscribe(value => { + expect(value).toEqual({ + coverImageAddress: "https://files.test/default.png", + isDefaultCover: true, + }); + }); + const req = TestBed.inject(HttpTestingController).expectOne( + "https://local.test/projects/31/reset-cover/", + ); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({}); + req.flush({ cover_image_address: "https://files.test/default.png", is_default_cover: true }); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts index d4c9ed8f0..03685922f 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts @@ -84,4 +84,14 @@ describe("ProjectHttpAdapter", () => { expect(api.get).toHaveBeenCalledExactlyOnceWith("/auth/users/projects/", params); }); + + it("resetCover отправляет команду проекта без физического DELETE", () => { + setup(); + api.post.mockReturnValue( + of({ coverImageAddress: "https://files.test/default.png", isDefaultCover: true }), + ); + adapter.resetCover(31).subscribe(); + expect(api.post).toHaveBeenCalledExactlyOnceWith("/projects/31/reset-cover/", {}); + expect(api.delete).not.toHaveBeenCalled(); + }); }); diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts index 105e4fed0..d6c8379a8 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts @@ -5,6 +5,7 @@ import { inject, Injectable } from "@angular/core"; import { ApiService } from "@corelib"; import { Observable } from "rxjs"; import { ProjectCountDto, ProjectDto, ProjectListDto } from "./dto/project.dto"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; /** HTTP-адаптер проектов: `/projects`, `/auth/users` (CRUD, счётчики, проекты лидера). */ @Injectable({ providedIn: "root" }) @@ -38,6 +39,11 @@ export class ProjectHttpAdapter { return this.apiService.delete(`${this.PROJECTS_URL}/${id}/`); } + /** Назначает стандартную обложку на сервере без DELETE общего файлового API. */ + resetCover(id: number): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${id}/reset-cover/`, {}); + } + fetchMy(params?: HttpParams): Observable { return this.apiService.get(`${this.AUTH_USERS_URL}/projects/`, params); } diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index cb07aa6b5..c7efb7d83 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -1,6 +1,7 @@ /** @format */ import { HttpParams } from "@angular/common/http"; +import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; import { inject, Injectable } from "@angular/core"; import { map, Observable, tap, throwError } from "rxjs"; import { User } from "@domain/auth/user.model"; @@ -35,6 +36,10 @@ import { /** Репозиторий программ: `EntityCache` для `getOne`, остальное — passthrough. */ @Injectable({ providedIn: "root" }) export class ProgramRepository implements ProgramRepositoryPort { + /** Не кеширует ролевую сводку: прежние данные не должны переживать смену контекста. */ + getRoleWidget(programId: number): Observable { + return this.programAdapter.getRoleWidget(programId); + } private readonly programAdapter = inject(ProgramHttpAdapter); private readonly entityCache = new EntityCache(5 * 60 * 1000); private readonly eventBus = inject(EventBus); 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 8c71fa256..6edfd5671 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 } from "rxjs"; +import { of, Subject } from "rxjs"; import { HttpParams } from "@angular/common/http"; import { ProjectRepository } from "./project.repository"; import { ProjectHttpAdapter } from "../../adapters/project/project-http.adapter"; @@ -32,6 +32,7 @@ describe("ProjectRepository", () => { fetchMy: vi.fn(), postCreate: vi.fn(), deleteOne: vi.fn(), + resetCover: vi.fn(), }; TestBed.configureTestingModule({ providers: [ProjectRepository, { provide: ProjectHttpAdapter, useValue: adapter }], @@ -70,6 +71,29 @@ describe("ProjectRepository", () => { expect(adapter.fetchOne).toHaveBeenCalledTimes(1); }); + it.each([true, false])("detail сохраняет серверный isDefaultCover=%s", flag => { + setup(); + adapter.fetchOne.mockReturnValue(of({ id: 31, isDefaultCover: flag })); + repository.getOne(31).subscribe(project => expect(project.isDefaultCover).toBe(flag)); + }); + + it.each(["success", "cancel"])("resetCover инвалидирует detail после %s", outcome => { + setup(); + const response = new Subject(); + adapter.resetCover.mockReturnValue(response); + adapter.fetchOne.mockReturnValue(of({ id: 31, isDefaultCover: false })); + repository.getOne(31).subscribe(); + const subscription = repository.resetCover(31).subscribe(); + repository.getOne(31).subscribe(); + if (outcome === "success") { + response.next({ coverImageAddress: "https://files.test/default.png", isDefaultCover: true }); + response.complete(); + } else subscription.unsubscribe(); + adapter.fetchOne.mockReturnValue(of({ id: 31, isDefaultCover: true })); + repository.getOne(31).subscribe(project => expect(project.isDefaultCover).toBe(true)); + expect(adapter.fetchOne).toHaveBeenCalledTimes(3); + }); + it("refreshCount мапит в ProjectCount и пушит в count$", () => new Promise(done => { setup(); 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 66ebafa62..263188cd5 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 @@ -21,6 +21,8 @@ import { RejectVacancyResponse } from "@domain/vacancy/events/reject-vacancy-res import { EntityCache } from "@domain/shared/entity-cache"; import { AcceptInvite } from "@domain/invite/events/accept-invite.event"; import { LoggedOut } from "@domain/auth/events/logged-out.event"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; +import { defer, finalize } from "rxjs"; /** Репозиторий проектов с локальным кешем деталей, инвалидируемым по доменным событиям. */ @Injectable({ providedIn: "root" }) @@ -148,4 +150,17 @@ export class ProjectRepository implements ProjectRepositoryPort { deleteOne(id: number): Observable { return this.projectAdapter.deleteOne(id); } + + /** + * Инвалидирует detail при завершении и отмене: сервер мог сохранить сброс, + * даже если редактор закрылся до получения ответа или сеть оборвалась. + */ + resetCover(id: number): Observable { + return defer(() => { + this.entityCache.invalidate(id); + return this.projectAdapter + .resetCover(id) + .pipe(finalize(() => this.entityCache.invalidate(id))); + }); + } } diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.spec.ts b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.spec.ts index 878a861cc..e74a88746 100644 --- a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.spec.ts @@ -34,6 +34,7 @@ describe("ProfileMidSideComponent", () => { }) as unknown as User; beforeEach(async () => { + vi.useFakeTimers(); await TestBed.configureTestingModule({ imports: [ProfileMidSideComponent], providers: [ @@ -61,9 +62,30 @@ describe("ProfileMidSideComponent", () => { ui.applySetLoggedUserId("logged", 7); ui.applyInitProfile({ data: { user } }, 7); fixture.componentRef.setInput("user", user); - fixture.detectChanges(); + renderProfile(); + }); + + afterEach(() => { + try { + fixture?.destroy(); + expect(vi.getTimerCount()).toBe(0); + } finally { + TestBed.resetTestingModule(); + vi.restoreAllMocks(); + vi.useRealTimers(); + } }); + /** + * Завершает отложенный поиск textarea в ngx-autosize до удаления вложенной формы. + * Его таймер на 100 мс не отменяется в ngOnDestroy: поздний поиск способен заново + * зарегистрировать resize после destroy и обратиться к уже закрытому jsdom. + */ + function renderProfile(): void { + fixture.detectChanges(); + vi.runAllTimers(); + } + it("показывает центрируемый empty state владельцу пустого профиля", () => { const emptyState = fixture.nativeElement.querySelector( '[data-testid="empty-profile-state"]', @@ -77,7 +99,7 @@ describe("ProfileMidSideComponent", () => { const user = createUser(7, true, "Создаю образовательные проекты"); ui.applyInitProfile({ data: { user } }, 7); fixture.componentRef.setInput("user", user); - fixture.detectChanges(); + renderProfile(); const card: HTMLElement = fixture.nativeElement.querySelector(".about"); expect(card).not.toBeNull(); expect(card.querySelector(".about__title")?.textContent).toContain("обо мне"); @@ -91,23 +113,23 @@ describe("ProfileMidSideComponent", () => { ui.applyInitProfile({ data: { user: foreignEmpty } }, 10); fixture.componentRef.setInput("user", foreignEmpty); - fixture.detectChanges(); + renderProfile(); expect(ui.isProfileEmpty()).toBe(true); expect(fixture.nativeElement.querySelector('[data-testid="empty-profile-state"]')).toBeFalsy(); ui.applyInitProfile({ data: { user: ownFilled } }, 10); fixture.componentRef.setInput("user", ownFilled); - fixture.detectChanges(); + renderProfile(); expect(ui.isProfileEmpty()).toBe(false); expect(fixture.nativeElement.querySelector('[data-testid="empty-profile-state"]')).toBeFalsy(); expect(fixture.nativeElement.querySelector("app-news-form")).toBeTruthy(); ui.applyInitProfile({ data: { user: foreignEmpty } }, 10); fixture.componentRef.setInput("user", foreignEmpty); - fixture.detectChanges(); + renderProfile(); ui.applyInitProfile({ data: { user: ownFilled } }, 10); fixture.componentRef.setInput("user", ownFilled); - fixture.detectChanges(); + renderProfile(); expect(ui.isProfileEmpty()).toBe(false); expect(fixture.nativeElement.querySelector("app-news-form")).toBeTruthy(); @@ -118,9 +140,30 @@ describe("ProfileMidSideComponent", () => { const user = createUser(7, true, aboutMe); ui.applyInitProfile({ data: { user } }, 7); fixture.componentRef.setInput("user", user); - fixture.detectChanges(); + renderProfile(); expect(fixture.nativeElement.querySelector(".about__text p").textContent).toBe(aboutMe); expect(fixture.nativeElement.querySelector(".about .read-more")).toBeFalsy(); }); + + it("освобождает resize и таймеры формы при смене профиля и уничтожении fixture", () => { + const addListener = vi.spyOn(window, "addEventListener"); + const removeListener = vi.spyOn(window, "removeEventListener"); + const ownFilled = createUser(7, true, "Мой профиль"); + const foreignEmpty = createUser(20, false); + + for (const user of [ownFilled, foreignEmpty, ownFilled]) { + ui.applyInitProfile({ data: { user } }, 7); + fixture.componentRef.setInput("user", user); + renderProfile(); + } + fixture.destroy(); + + const resizeListeners = addListener.mock.calls.filter(([event]) => event === "resize"); + expect(resizeListeners.length).toBeGreaterThan(0); + for (const [event, callback, options] of resizeListeners) { + expect(removeListener).toHaveBeenCalledWith(event, callback, options); + } + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html index 8dafbcb17..8363c9905 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html @@ -4,7 +4,7 @@
- @if (!program()!.isUserMember && !program()!.isUserManager) { + @if (!program()!.isUserMember && !program()!.isUserManager && !program()!.isUserExpert) {
1000) {
@@ -56,17 +55,19 @@

о программе

(addNews)="onAddNews($event)" > } - @for (n of news(); track n.id) { - + @if (program()!.isUserMember || program()!.isUserManager) { + @for (n of news(); track n.id) { + + } }
diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts index b9cd5d07a..285fe23e1 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts @@ -17,7 +17,7 @@ import { RouterModule } from "@angular/router"; import { ButtonComponent, IconComponent } from "@ui/primitives"; import { ModalComponent } from "@ui/primitives/modal/modal.component"; import { MatProgressBarModule } from "@angular/material/progress-bar"; -import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { ProgramRoleWidgetComponent } from "./role-widget/program-role-widget.component"; import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component"; import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; @@ -44,7 +44,7 @@ import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.c ParseLinksPipe, ModalComponent, MatProgressBarModule, - SoonCardComponent, + ProgramRoleWidgetComponent, NewsFormComponent, ModalComponent, MatProgressBarModule, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.roles.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.roles.spec.ts new file mode 100644 index 000000000..3f9e31b43 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.roles.spec.ts @@ -0,0 +1,173 @@ +/** @format */ + +import { Component, input, output } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, provideRouter } from "@angular/router"; +import { By } from "@angular/platform-browser"; +import { BehaviorSubject, of } from "rxjs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProgramDetailMainComponent } from "./main.component"; +import { ProgramRoleWidgetComponent } from "./role-widget/program-role-widget.component"; +import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component"; +import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; +import { ProgramDetailMainService } from "@api/program/facades/detail/program-detail-main-info.service"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { NewsInfoService } from "@api/news/news-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { LoadingService } from "@api/shared/loading.service"; +import { FetchNewsUseCase } from "@api/program/use-cases/fetch-news.use-case"; +import { AddNewsUseCase } from "@api/program/use-cases/add-news.use-case"; +import { ReadNewsUseCase } from "@api/program/use-cases/read-news.use-case"; +import { DeleteNewsUseCase } from "@api/program/use-cases/delete-news.use-case"; +import { EditNewsUseCase } from "@api/program/use-cases/edit-news.use-case"; +import { ToggleLikeUseCase } from "@api/program/use-cases/toggle-like.use-case"; +import { AcknowledgeProgramWelcomeUseCase } from "@api/program/use-cases/acknowledge-program-welcome.use-case"; +import { Program } from "@domain/program/program.model"; +import { FeedNews } from "@domain/news/project-news.model"; +import { ok } from "@domain/shared/result.type"; +import { signal } from "@angular/core"; + +@Component({ selector: "app-news-form", template: "Форма новости" }) +class NewsFormStub { + pending = input(false); + addNews = output<{ text: string; files: string[] }>(); +} +@Component({ selector: "app-news-card", template: "{{ feedItem().text }}" }) +class NewsCardStub { + feedItem = input.required(); + resourceLink = input(); + isOwner = input(false); + like = output(); + edited = output(); + delete = output(); +} +@Component({ selector: "app-program-role-widget", template: "Виджет" }) +class WidgetStub {} + +describe("Program page: program-scoped news and role visibility", () => { + const news = { ...FeedNews.default(), id: 1, text: "Существующая новость", files: [] }; + const added = { ...news, id: 2, text: "Созданная организатором новость" }; + const fetch = vi.fn(), + add = vi.fn(); + let data: BehaviorSubject<{ data: Program }>; + + beforeEach(async () => { + vi.useFakeTimers(); + data = new BehaviorSubject({ data: Program.default() }); + fetch.mockReset().mockReturnValue(of(ok({ count: 1, results: [news] }))); + add.mockReset().mockReturnValue(of(ok(added))); + vi.spyOn(ProgramDetailMainService.prototype, "initScroll").mockImplementation(() => {}); + await TestBed.configureTestingModule({ + imports: [ProgramDetailMainComponent], + providers: [ + provideRouter([]), + ProgramDetailMainUIInfoService, + NewsInfoService, + { + provide: ActivatedRoute, + useValue: { + params: of({ programId: 12 }), + queryParams: of({}), + data, + snapshot: { params: { programId: 12 } }, + }, + }, + { provide: LoadingService, useValue: { hide: vi.fn() } }, + { provide: FetchNewsUseCase, useValue: { execute: fetch } }, + { provide: AddNewsUseCase, useValue: { execute: add } }, + { + provide: ProjectAdditionalService, + useValue: { + isSend$: signal({ status: "initial" }), + errorAssignProjectToProgramModalMessage: signal(null), + }, + }, + ...[ + ReadNewsUseCase, + DeleteNewsUseCase, + EditNewsUseCase, + ToggleLikeUseCase, + AcknowledgeProgramWelcomeUseCase, + ].map(provide => ({ provide, useValue: {} })), + ], + }) + .overrideComponent(ProgramDetailMainComponent, { + remove: { + imports: [NewsFormComponent, NewsCardComponent, ProgramRoleWidgetComponent], + providers: [ExpandService], + }, + add: { + imports: [NewsFormStub, NewsCardStub, WidgetStub], + providers: [ + { + provide: ExpandService, + useValue: { + descriptionExpandable: signal(false), + readFullDescription: signal(false), + checkExpandable: vi.fn(), + }, + }, + ], + }, + }) + .compileComponents(); + }); + afterEach(() => { + vi.runOnlyPendingTimers(); + TestBed.resetTestingModule(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + function render(role: Partial) { + data.next({ + data: { + ...Program.default(), + id: 12, + isUserMember: false, + isUserManager: false, + isUserExpert: false, + welcomeAcknowledgedAt: "2026-09-01T00:00:00Z", + ...role, + }, + }); + const fixture = TestBed.createComponent(ProgramDetailMainComponent); + fixture.componentInstance["appWidth"] = 1280; + fixture.detectChanges(); + return fixture; + } + it("loads and shows both existing and newly created news for manager without member role", () => { + const f = render({ isUserManager: true }); + expect(fetch).toHaveBeenCalledExactlyOnceWith(10, 0, 12); + expect(f.nativeElement.textContent).toContain(news.text); + const form = f.debugElement.query(By.directive(NewsFormStub)); + expect(form).toBeTruthy(); + form.componentInstance.addNews.emit({ text: added.text, files: [] }); + f.detectChanges(); + expect(add).toHaveBeenCalledExactlyOnceWith(12, { text: added.text, files: [] }); + expect(f.nativeElement.textContent).toContain(added.text); + expect(f.nativeElement.querySelectorAll("app-news-card")).toHaveLength(2); + }); + it("keeps member news without granting the creation form", () => { + const f = render({ isUserMember: true }); + expect(fetch).toHaveBeenCalledOnce(); + expect(f.nativeElement.textContent).toContain(news.text); + expect(f.nativeElement.querySelector("app-news-form")).toBeNull(); + }); + it("shows expert widget without requesting or revealing member/manager news", () => { + const f = render({ isUserExpert: true }); + TestBed.inject(NewsInfoService).applyAddNews(news); + f.detectChanges(); + expect(f.nativeElement.querySelector("app-program-role-widget")).toBeTruthy(); + expect(f.nativeElement.querySelector("app-news-card,app-news-form")).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + it("preserves advertisement and no internal content for an unrelated user", () => { + const f = render({}); + expect(f.nativeElement.querySelector(".program__advertisement")).toBeTruthy(); + expect( + f.nativeElement.querySelector("app-program-role-widget,app-news-card,app-news-form"), + ).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html new file mode 100644 index 000000000..3de919221 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html @@ -0,0 +1,201 @@ + + +
+
+

аналитика

+ {{ data() ? roleNames[data()!.role] : " " }} +
+ @if (data(); as data) { +
+ @switch (data.role) { + @case ("participant") { + @let participant = data.participant; + @let project = participant.participantProject; + @let caseText = + participant.caseProvided + ? participant.caseName + ? "Кейс: " + participant.caseName + : "Кейс не выбран" + : "Кейсы не предусмотрены"; +
+ @if (participant.caseName) { + + Кейс:{{ participant.caseName }} + + } @else { + {{ caseText }} + } + @if (project) { + + Проект:{{ project.name }} + + } @else { + Проект не подан + } +
+
+ @if (participant.stage === "not_submitted") { + Не отправлен + } + @if (participant.stage === "not_applicable") { + Сдача не предусмотрена + } +
+
    + @for (step of steps(participant.stage); track step.label) { +
  1. + + {{ step.label }} +
  2. + } +
+ } + @case ("organizer") { + @let metrics = data.organizer; + @let ratio = withoutProject(metrics); +
+
+
Участники
+
+ {{ count(metrics.participants) }} +
+
+
+
Проекты
+
+ {{ count(metrics.projects) }} +
+
+
+
Сдано
решений
+
+ {{ count(metrics.submittedSolutions) }} +
+
+
+
Без проектов
+
+ {{ count(metrics.participantsWithoutProject) }} + {{ ratio.percentage }} +
+
+
+ } + @case ("expert") { +
+
+
Назначено
проектов
+
+ {{ count(data.expert.assigned) }} +
+
+
+
Осталось
оценить
+
+ {{ count(data.expert.remaining) }} +
+
+
+ @if (expertState(); as status) { +

+ @if (status.label) { + {{ status.label }} + } + {{ status.value }} +

+ } + } + } +
+
+ @switch (data.role) { + @case ("organizer") { + Открыть → + } + @case ("expert") { + Открыть → + } + @case ("participant") { + @if (data.participant.participantProject; as project) { + Открыть → + } @else if (data.participant.submissionOpen) { + + } @else { + Срок подачи закрыт + } + } + } +
+ } @else if (state(); as state) { + @if (state.status === "failure") { +

{{ errorMessages[state.error] }}

+
+ @if (state.error === "network") { + + } +
+ } @else { +
+ +
+
+ } + } +
diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.scss new file mode 100644 index 000000000..d809a58b6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.scss @@ -0,0 +1,294 @@ +:host { + display: block; + width: 157px; +} + +.widget { + box-sizing: border-box; + display: grid; + grid-template-rows: 18px minmax(0, 1fr) 22px; + row-gap: 5px; + width: 100%; + + // Исходная карточка при 100%: 157 × 140.984375 CSS px (Mont, desktop). + height: 140.9844px; + padding: 7px 8px; + font-size: 10px; + line-height: 13px; + color: var(--black); + background: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__header { + display: flex; + gap: 5px; + align-items: center; + justify-content: space-between; + } + + &__badge { + min-height: 12px; + padding: 1px 4px; + font-size: 8px; + line-height: 12px; + color: var(--accent); + background: var(--accent-light); + border-radius: 8px; + } + + h3 { + font-size: 12px; + font-weight: 600; + line-height: 15px; + color: var(--accent); + } + + &__body { + min-width: 0; + min-height: 0; + } + + &__ellipsis { + display: block; + min-width: 0; + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; + white-space: nowrap; + } + + &__project-lines { + display: grid; + gap: 3px; + line-height: 14px; + } + + &__detail-row { + display: flex; + gap: 3px; + min-width: 0; + color: var(--black); + } + + &__label { + flex-shrink: 0; + color: var(--grey-for-text); + } + + &__empty { + color: var(--grey-for-text); + } + + &__stage-state { + height: 12px; + margin-top: 3px; + font-size: 10px; + line-height: 12px; + color: var(--grey-for-text); + } + + &__steps { + display: flex; + justify-content: space-between; + padding: 0; + margin: 0; + list-style: none; + } + + &__steps li { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + font-size: 8px; + line-height: 11px; + color: var(--grey-for-text); + } + + &__steps li:not(:last-child)::after { + position: absolute; + top: 6px; + left: 50%; + width: 100%; + height: 1px; + content: ""; + background: var(--medium-grey-for-outline); + } + + &__steps li.widget__step--complete::after { + background: var(--accent); + } + + &__dot { + position: relative; + z-index: 1; + display: block; + width: 12px; + height: 12px; + margin-bottom: 3px; + line-height: 12px; + text-align: center; + background: var(--medium-grey-for-outline); + border: 1px solid transparent; + border-radius: 50%; + } + + &__step--complete .widget__dot { + color: var(--light-white); + background: var(--accent); + } + + &__step--current .widget__dot { + background: var(--light-white); + border-color: var(--accent); + } + + &__steps .widget__step--current { + color: var(--accent); + } + + &__metrics { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 4px 6px; + margin: 0; + } + + dt { + font-size: 10px; + line-height: 12px; + color: var(--grey-for-text); + } + + dd { + margin: 0; + overflow: hidden; + font-size: 14px; + font-weight: 600; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__metrics--organizer > div:nth-child(n + 3) dt { + min-height: 24px; + } + + &__without span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + &__without { + display: flex; + gap: 4px; + align-items: center; + } + + &__without small { + display: flex; + flex-shrink: 0; + gap: 3px; + align-items: center; + font-size: 9px; + font-weight: 400; + line-height: 12px; + } + + &__without i { + width: 3px; + height: 3px; + background: currentcolor; + border-radius: 50%; + } + + &__expert-state { + display: grid; + align-content: center; + min-height: 28px; + margin-top: 6px; + font-size: 10px; + line-height: 12px; + } + + &__status-label { + color: var(--grey-for-text); + } + + &__status-value--time { + font-size: 12px; + font-weight: 600; + line-height: 16px; + } + + &__footer { + display: flex; + align-items: center; + justify-content: center; + border-top: 0.5px solid var(--medium-grey-for-outline); + } + + &__footer a, + &__footer button { + padding: 0; + font: inherit; + color: var(--accent); + text-align: center; + cursor: pointer; + background: transparent; + border: 0; + } + + &__footer span { + font-size: 9px; + color: var(--grey-for-text); + } + + &__footer button:disabled { + cursor: default; + opacity: 0.5; + } + + &__error { + display: grid; + place-items: center; + text-align: center; + } + + &__skeleton { + display: grid; + gap: 7px; + padding: 6px 0; + } + + &__skeleton span { + background: var(--medium-grey-for-outline); + border-radius: 4px; + } + + [data-tone="green"] { + color: var(--green-dark); + } + + [data-tone="yellow"] { + color: var(--gold-dark); + } + + [data-tone="red"] { + color: var(--red-dark); + } + + [data-tone="neutral"] { + color: var(--grey-for-text); + } + + a:focus-visible, + button:focus-visible, + [tabindex]:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 1px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.spec.ts new file mode 100644 index 000000000..591a77eb1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.spec.ts @@ -0,0 +1,200 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { signal } from "@angular/core"; +import { provideRouter } from "@angular/router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProgramRoleWidgetComponent } from "./program-role-widget.component"; +import { ProgramRoleWidgetService } from "@api/program/facades/detail/program-role-widget.service"; +import { DetailProgramInfoService } from "@ui/widgets/detail/services/program/detail-program-info.service"; +import { AsyncState, failure, loading, success } from "@domain/shared/async-state"; +import { ProgramRoleWidget, ProgramWidgetError } from "@domain/program/program-role-widget.model"; + +describe("ProgramRoleWidgetComponent", () => { + const state = signal>(loading()); + const retry = vi.fn(), + create = vi.fn(); + const participant: ProgramRoleWidget = { + role: "participant", + programId: 12, + isCompetitive: true, + participant: { + participantProject: { id: 5, name: "Длинное название ".repeat(20), programLinkId: 34 }, + caseProvided: true, + caseName: "Кейс ".repeat(30), + stage: "review", + submissionOpen: true, + }, + }; + beforeEach(async () => { + retry.mockClear(); + create.mockClear(); + state.set(loading()); + TestBed.configureTestingModule({ + imports: [ProgramRoleWidgetComponent], + providers: [ + provideRouter([]), + { + provide: DetailProgramInfoService, + useValue: { addNewProject: create, applicationPending: signal(false) }, + }, + ], + }); + TestBed.overrideComponent(ProgramRoleWidgetComponent, { + set: { + providers: [ + { provide: ProgramRoleWidgetService, useValue: { state, retry, programId: signal(12) } }, + ], + }, + }); + await TestBed.compileComponents(); + }); + function render(data?: ProgramRoleWidget) { + if (data) state.set(success(data)); + const fixture = TestBed.createComponent(ProgramRoleWidgetComponent); + fixture.detectChanges(); + return fixture; + } + it("shows skeleton without bogus zero counters", () => { + const f = render(); + expect(f.nativeElement.querySelector(".widget__skeleton")).toBeTruthy(); + expect(f.nativeElement.querySelector("dd")).toBeNull(); + }); + it("opens the exact project from both name and action and retains full accessible names", () => { + const f = render(participant); + const links = f.nativeElement.querySelectorAll("a"); + expect(links).toHaveLength(2); + for (const link of links) + expect(link.getAttribute("href")).toBe("/office/projects/5?programLinkId=34"); + expect(links[0].textContent).toContain(participant.participant.participantProject!.name); + expect(f.nativeElement.querySelector('[aria-current="step"]').textContent).toContain( + "Проверка", + ); + expect(f.nativeElement.querySelectorAll(".widget__step--complete")).toHaveLength(1); + }); + it("does not create or submit automatically and uses existing explicit action", () => { + const f = render({ + ...participant, + participant: { ...participant.participant, participantProject: null, stage: "none" }, + }); + expect(create).not.toHaveBeenCalled(); + f.nativeElement.querySelector("button").click(); + expect(create).toHaveBeenCalledWith(12); + }); + it("renders the server-evaluated project as a completed chain", () => { + const f = render({ + ...participant, + participant: { ...participant.participant, stage: "evaluated" }, + }); + expect(f.nativeElement.querySelectorAll(".widget__step--complete")).toHaveLength(3); + expect(f.nativeElement.querySelector('[aria-current="step"]')).toBeNull(); + }); + it("has no broken action when submission is closed", () => { + const f = render({ + ...participant, + participant: { + ...participant.participant, + participantProject: null, + stage: "none", + submissionOpen: false, + }, + }); + expect(f.nativeElement.querySelector("button,a")).toBeNull(); + expect(f.nativeElement.textContent).toContain("Срок подачи закрыт"); + }); + it("keeps a draft project link usable after submission closes", () => { + const f = render({ + ...participant, + participant: { ...participant.participant, stage: "not_submitted", submissionOpen: false }, + }); + expect(f.nativeElement.textContent).toContain("Не отправлен"); + expect(f.nativeElement.querySelectorAll("a")).toHaveLength(2); + }); + it("shows four organizer counters and one inline percentage", () => { + const f = render({ + role: "organizer", + programId: 12, + isCompetitive: true, + organizer: { + participants: 248, + projects: 61, + submittedSolutions: 54, + participantsWithoutProject: 19, + }, + }); + expect(f.nativeElement.querySelectorAll("dd")).toHaveLength(4); + expect(f.nativeElement.textContent).toContain("7,7%"); + expect(f.nativeElement.querySelector("a").getAttribute("href")).toBe( + "/office/program/12/analytics", + ); + }); + it("shows only two expert counters and the real evaluation route", () => { + const f = render({ + role: "expert", + programId: 12, + isCompetitive: true, + expert: { + mode: "distributed", + assigned: 0, + remaining: 0, + evaluationEnds: "2020-01-01T00:00:00Z", + }, + }); + expect(f.nativeElement.querySelectorAll("dd")).toHaveLength(2); + expect(f.nativeElement.textContent).toContain("Нет назначенных проектов"); + expect(f.nativeElement.querySelector("a").getAttribute("href")).toBe( + "/office/program/12/projects-rating", + ); + }); + it("renders open counters as inapplicable", () => { + const f = render({ + role: "expert", + programId: 12, + isCompetitive: true, + expert: { mode: "open", assigned: null, remaining: null, evaluationEnds: null }, + }); + expect( + [...f.nativeElement.querySelectorAll("dd")].map((e: any) => e.textContent.trim()), + ).toEqual(["—", "—"]); + expect(f.nativeElement.textContent).toContain("Свободное оценивание"); + }); + it("updates across the deadline and releases its local clock on destroy", () => { + vi.useFakeTimers(); + const now = Date.parse("2026-09-01T12:00:00Z"); + vi.setSystemTime(now); + try { + const f = render({ + role: "expert", + programId: 12, + isCompetitive: true, + expert: { + mode: "distributed", + assigned: 1, + remaining: 1, + evaluationEnds: new Date(now + 1000).toISOString(), + }, + }); + expect(f.nativeElement.textContent).toContain("1 мин"); + vi.advanceTimersByTime(1000); + f.detectChanges(); + expect(f.nativeElement.textContent).toContain("Срок оценивания истёк"); + const lastTick = f.componentInstance.now(); + f.destroy(); + vi.advanceTimersByTime(1000); + expect(f.componentInstance.now()).toBe(lastTick); + } finally { + vi.useRealTimers(); + } + }); + it("retries only network failure and never displays raw body", () => { + state.set(failure("network")); + const f = render(); + expect(f.nativeElement.textContent).toContain("Не удалось загрузить"); + f.nativeElement.querySelector("button").click(); + expect(retry).toHaveBeenCalledOnce(); + state.set(failure("forbidden")); + f.detectChanges(); + expect(f.nativeElement.querySelector("button")).toBeNull(); + expect(f.nativeElement.textContent).toContain("Нет доступа"); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.ts new file mode 100644 index 000000000..b60876ab2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.ts @@ -0,0 +1,82 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + signal, +} from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ProgramRoleWidgetService } from "@api/program/facades/detail/program-role-widget.service"; +import { + expertWidgetPresentation, + participantSteps, + withoutProjectPresentation, +} from "@api/program/program-widget-presentation"; +import { DetailProgramInfoService } from "@ui/widgets/detail/services/program/detail-program-info.service"; + +/** Компактный виджет только страницы программы. Габариты соответствуют исходному SoonCard. */ +@Component({ + selector: "app-program-role-widget", + imports: [RouterLink, MatTooltipModule], + providers: [ProgramRoleWidgetService], + templateUrl: "./program-role-widget.component.html", + styleUrl: "./program-role-widget.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramRoleWidgetComponent { + readonly service = inject(ProgramRoleWidgetService); + private readonly applicationAction = inject(DetailProgramInfoService); + readonly applicationPending = this.applicationAction.applicationPending; + readonly state = this.service.state; + readonly data = computed(() => { + const state = this.state(); + return state.status === "success" ? state.data : null; + }); + readonly roleNames = { organizer: "организатор", expert: "эксперт", participant: "участник" }; + readonly routes = AppRoutes; + readonly steps = participantSteps; + readonly withoutProject = withoutProjectPresentation; + readonly now = signal(Date.now()); + readonly expertState = computed(() => { + const data = this.data(); + return data?.role === "expert" + ? expertWidgetPresentation(data.expert, data.isCompetitive, this.now()) + : null; + }); + readonly errorMessages = { + network: "Не удалось загрузить", + forbidden: "Нет доступа", + unauthorized: "Войдите в аккаунт", + not_found: "Программа недоступна", + integrity: "Данные проекта требуют проверки", + }; + constructor() { + // Только локальные часы: никаких повторных HTTP-запросов. Пересечение дедлайна ≤1 с. + const clock = setInterval(() => this.now.set(Date.now()), 1000); + inject(DestroyRef).onDestroy(() => clearInterval(clock)); + } + /** Неприменимое значение отличается от нуля; крупное число не расширяет карточку. */ + count(value: number | null): string { + return value === null + ? "—" + : new Intl.NumberFormat("ru-RU", { + notation: value >= 10000 ? "compact" : "standard", + maximumFractionDigits: 1, + }).format(value); + } + /** Запускает существующий выбор проекта только пока разрешена подача. */ + createApplication(): void { + const data = this.data(); + if ( + data?.role === "participant" && + !data.participant.participantProject && + data.participant.submissionOpen + ) + this.applicationAction.addNewProject(data.programId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html index c20f62dbc..7f6c913ee 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html @@ -57,19 +57,15 @@ - - -

- Презентации формата .jpg, .jpeg, .png -
Размер изображения 1280 x 230 -

- @if (showError(coverImageAddress, "required")) { -

Загрузите файл

- } -
-
+ > } @if (trl; as trl) { diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts index 7f05b7c3e..6ccf81b95 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts @@ -16,14 +16,31 @@ import { ProjectGoalService } from "@api/project/facades/edit/project-goals.serv import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; import { ProjectMainStepComponent } from "./project-main-step.component"; import { createProjectForm } from "@api/project/facades/edit/project-form.factory"; +import { ResetProjectCoverUseCase } from "@api/project/use-cases/reset-project-cover.use-case"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { FileService } from "@core/lib/services/file/file.service"; +import { initial } from "@domain/shared/async-state"; +import { Subject } from "rxjs"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; describe("ProjectMainStepComponent", () => { let fixture: ComponentFixture; let links: FormArray; + const resetCover = { execute: vi.fn() }; + const files = { deleteFile: vi.fn(), uploadFile: vi.fn() }; beforeEach(async () => { const fb = new FormBuilder(); const projectForm = createProjectForm(fb); + resetCover.execute.mockReset().mockReturnValue( + of({ + ok: true, + value: { coverImageAddress: "https://files.test/default.png", isDefaultCover: true }, + }), + ); + files.deleteFile.mockReset().mockReturnValue(of(undefined)); + files.uploadFile.mockReset().mockReturnValue(of({ url: "https://files.test/custom.png" })); + projectForm.get("coverImageAddress")?.setValue("https://files.test/default.png"); const emptyProjectControls = { name: projectForm.get("name"), region: projectForm.get("region"), @@ -36,7 +53,7 @@ describe("ProjectMainStepComponent", () => { trl: null, partnerProgramId: null, presentationAddress: null, - coverImageAddress: null, + coverImageAddress: projectForm.get("coverImageAddress"), imageAddress: null, }; links = projectForm.get("links") as FormArray; @@ -48,17 +65,28 @@ describe("ProjectMainStepComponent", () => { provideRouter([]), provideNgxMask(), ProjectContactsService, + ProjectCoverResetService, + { provide: ResetProjectCoverUseCase, useValue: resetCover }, + { provide: FileService, useValue: files }, { provide: ProjectFormService, useValue: { getForm: () => projectForm, editIndex: signal(null), + isDefaultCover: signal(true), + currentProjectId: signal(31), + contextChanged$: new Subject(), ...emptyProjectControls, }, }, { provide: ProjectsEditInfoService, - useValue: { projectForm, profileId: signal(1), industries$: of([]) }, + useValue: { + projectForm, + profileId: signal(1), + industries$: of([]), + projFormIsSubmitting$: signal(initial()), + }, }, { provide: ProjectsEditUIInfoService, @@ -95,6 +123,83 @@ describe("ProjectMainStepComponent", () => { fixture.detectChanges(); }); + it("стандартная обложка предлагает загрузку и не выдаёт системный URL за загруженный файл", () => { + const upload = fixture.debugElement.query(By.css('[formControlName="coverImageAddress"]')) + .componentInstance as UploadFileComponent; + expect( + fixture.nativeElement.querySelector('[formcontrolname="coverImageAddress"] .file__basket'), + ).toBeNull(); + expect(fixture.nativeElement.querySelector(".project__cover-reset")).toBeNull(); + const cover = fixture.nativeElement.querySelector('[formcontrolname="coverImageAddress"]'); + expect(cover.textContent).toContain("Стандартная обложка"); + expect(cover.textContent).not.toContain("Файл успешно загружен"); + expect(cover.querySelector(".file__replace").textContent.trim()).toBe("Загрузить файл"); + expect(cover.querySelector('[icon="file-success"]')).toBeNull(); + expect(upload.value).toBe("https://files.test/default.png"); + upload.onRemove(); + expect(files.deleteFile).not.toHaveBeenCalled(); + expect(resetCover.execute).not.toHaveBeenCalled(); + }); + + it("пользовательская обложка предлагает только замену с клавиатуры, без кнопки сброса", async () => { + const form = TestBed.inject(ProjectFormService); + form.isDefaultCover.set(false); + form.coverImageAddress?.setValue("https://files.test/custom.png"); + await fixture.whenStable(); + const cover = fixture.nativeElement.querySelector('[formcontrolname="coverImageAddress"]'); + const replace = cover.querySelector(".file__replace") as HTMLButtonElement; + const fileInput = cover.querySelector('input[type="file"]') as HTMLInputElement; + const chooseFile = vi.spyOn(fileInput, "click"); + expect(replace.textContent?.trim()).toBe("Заменить файл"); + expect(cover.textContent).not.toContain("Стандартная обложка"); + expect(cover.textContent).toContain("Файл успешно загружен"); + replace.focus(); + expect(document.activeElement).toBe(replace); + replace.click(); + expect(chooseFile).toHaveBeenCalledOnce(); + expect(resetCover.execute).not.toHaveBeenCalled(); + expect(form.coverImageAddress?.value).toBe("https://files.test/custom.png"); + expect(fixture.nativeElement.querySelector(".project__cover-reset")).toBeNull(); + const upload = fixture.debugElement.query(By.css('[formControlName="coverImageAddress"]')) + .componentInstance as UploadFileComponent; + expect(upload.value).toBe("https://files.test/custom.png"); + expect(files.deleteFile).not.toHaveBeenCalled(); + }); + + it("замена стандартной и пользовательской обложки загрузкой не вызывает DELETE", async () => { + for (const url of ["https://files.test/custom-a.png", "https://files.test/custom-b.png"]) { + files.uploadFile.mockReturnValue(of({ url })); + const upload = fixture.debugElement.query(By.css('[formControlName="coverImageAddress"]')) + .componentInstance as UploadFileComponent; + upload.onUpdate({ + currentTarget: { files: [new File(["png"], "cover.png", { type: "image/png" })] }, + } as unknown as Event); + await fixture.whenStable(); + expect(TestBed.inject(ProjectFormService).coverImageAddress?.value).toBe(url); + expect(TestBed.inject(ProjectFormService).isDefaultCover()).toBe(false); + expect(fixture.nativeElement.querySelector(".project__cover-reset")).toBeNull(); + const cover = fixture.nativeElement.querySelector('[formcontrolname="coverImageAddress"]'); + expect(cover.querySelector(".file__replace").textContent.trim()).toBe("Заменить файл"); + expect(cover.textContent).not.toContain("Стандартная обложка"); + } + expect(files.deleteFile).not.toHaveBeenCalled(); + }); + + it("пустая обложка показывает zero state, не записывая фиктивный URL в обязательный control", async () => { + const form = TestBed.inject(ProjectFormService); + form.isDefaultCover.set(null); + form.coverImageAddress?.setValue(""); + await fixture.whenStable(); + const cover = fixture.nativeElement.querySelector('[formcontrolname="coverImageAddress"]'); + expect(cover.textContent).toContain("Стандартная обложка"); + expect(cover.querySelector(".file__replace").textContent.trim()).toBe("Загрузить файл"); + expect(cover.textContent).not.toContain("Файл успешно загружен"); + expect(form.coverImageAddress?.value).toBe(""); + expect(form.coverImageAddress?.hasError("required")).toBe(true); + expect(files.uploadFile).not.toHaveBeenCalled(); + expect(resetCover.execute).not.toHaveBeenCalled(); + }); + it("untouched fields are quiet; submit exposes matching borders, warning icons and required messages", async () => { const required = ["name", "region", "industry", "problem", "description", "targetAudience"]; for (const id of required) { diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts index 625fc492e..d7f4cc359 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts @@ -31,6 +31,7 @@ import { ProjectGoalService } from "@api/project/facades/edit/project-goals.serv import { ProjectContactsService } from "@api/project/facades/edit/project-contacts.service"; import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; import { RegionSelectComponent } from "@ui/widgets/region-select/region-select.component"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; /** Шаг редактирования проекта: основная информация. */ @Component({ @@ -62,6 +63,8 @@ export class ProjectMainStepComponent implements OnInit { private readonly fb = inject(FormBuilder); private readonly projectFormService = inject(ProjectFormService); + protected readonly coverReset = inject(ProjectCoverResetService); + protected readonly isDefaultCover = this.projectFormService.isDefaultCover; private readonly projectTeamUIService = inject(ProjectTeamUIService); private readonly projectsEditInfoService = inject(ProjectsEditInfoService); @@ -132,7 +135,7 @@ export class ProjectMainStepComponent implements OnInit { ngOnInit(): void {} - /** A submit attempt exposes actual errors; it must never make a valid control look required. */ + /** Показывает реальные ошибки после отправки, не превращая валидное поле в обязательное. */ protected showError(control: AbstractControl, errorName?: string): boolean { return ( control.enabled && diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html index 015524a58..800ecc2d9 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html @@ -19,6 +19,7 @@

редактировать проект

customTypographyClass="text-body-12" appearance="outline" [loader]="projFormIsSubmittingAsDraft()" + [disabled]="coverReset.pending()" (click)="saveProjectAsDraft()" > сохранить черновик @@ -28,7 +29,7 @@

редактировать проект

customTypographyClass="text-body-12" [loader]="projFormIsSubmittingAsPublished()" (click)="saveProjectAsPublished()" - [disabled]="onEditClicked()" + [disabled]="onEditClicked() || coverReset.pending()" [style.opacity]="onEditClicked() ? '0.5' : '1'" > {{ isCompetitive() && !submitted() ? "отправить заявку" : "сохранить" }} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts index 3a987cdc3..63895a13e 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts @@ -21,6 +21,7 @@ import { ProjectsEditUIInfoService } from "@api/project/facades/edit/ui/projects import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; import { ProjectStepService } from "@api/project/project-step.service"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; describe("ProjectEditComponent", () => { let component: ProjectEditComponent; @@ -83,6 +84,7 @@ describe("ProjectEditComponent", () => { .overrideComponent(ProjectEditComponent, { remove: { providers: [ + ProjectCoverResetService, ProjectsEditInfoService, ProjectsEditUIInfoService, ProjectStepService, @@ -102,6 +104,7 @@ describe("ProjectEditComponent", () => { }, add: { providers: [ + { provide: ProjectCoverResetService, useValue: { pending: signal(false) } }, { provide: ProjectsEditInfoService, useValue: projectsEditInfoServiceSpy, @@ -148,6 +151,14 @@ describe("ProjectEditComponent", () => { expect(component).toBeTruthy(); }); + it("на любом шаге блокирует сохранение старой обложки во время reset", () => { + fixture.debugElement.injector.get(ProjectCoverResetService).pending.set(true); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll(".project__save app-button button"); + expect(buttons[1].disabled).toBe(true); + expect(buttons[2].disabled).toBe(true); + }); + it("uses canonical competition/submitted state for the primary action label", () => { const facade = fixture.debugElement.injector.get(ProjectsEditInfoService); // Mutable test signals stand in for the facade's canonical readonly signals. diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts index f55c10dad..416c89ab0 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts @@ -39,6 +39,7 @@ import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; import { AppRoutes } from "@api/paths/app-routes"; import { EditStep } from "@core/lib/models/edit-step"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; /** Многошаговое редактирование проекта. */ @Component({ @@ -62,6 +63,7 @@ import { EditStep } from "@core/lib/models/edit-step"; ], providers: [ ProjectFormService, + ProjectCoverResetService, ProjectVacancyService, ProjectVacancyUIService, ProjectTeamService, @@ -79,6 +81,7 @@ import { EditStep } from "@core/lib/models/edit-step"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { + protected readonly coverReset = inject(ProjectCoverResetService); private readonly projectsEditInfoService = inject(ProjectsEditInfoService); protected readonly AppRoutes = AppRoutes; private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html index cc5a2e05d..455d174a2 100644 --- a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html @@ -3,9 +3,16 @@
@if (loading) { - } @else if (!value) { + } @else if (!value && !presetLabel()) {
- +